diff --git a/.ast-bro-ignore b/.ast-bro-ignore new file mode 100644 index 00000000..74945dc2 --- /dev/null +++ b/.ast-bro-ignore @@ -0,0 +1,4 @@ +vendor/ +helpers/ +.agents/ +CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index b1a8e6af..db0e6081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,5 @@ # Organization For novel features, prefer setting them into new files. Place in the correct crates. -Avoid files over 1000 LOC. When found, suggest modularizing. # UI development Always prefer using the gpui-component library components over GPUI primitives, unless it cannot be made fit. @@ -9,5 +8,6 @@ Always prefer using the gpui-component library components over GPUI primitives, Always consider pre-existing crates to handle operations, especially computationally heavy tasks (searching, replacement, or anything that has likely been solved externally already). Use cargo via the CLI rather than directly editing cargo files. # Post-edit checks -Usually avoid `cargo check`, `cargo build`, `cargo run`, or `cargo fmt`. -Main agents should run `cargo clippy` when intended edits are implemented. Fix clippy suggestions if applicable (if it is not false positive or causing regression). +Avoid `cargo check`, `cargo build`, `cargo run`, or `cargo fmt`. +Main agents should run `cargo clippy` when ALL intended edits are implemented. Fix clippy suggestions if applicable (if it is not false positive or causing regression). +Never EVER run any verification command like this until you can call your goal fully complete. Do not run it after 'stages,' or 'individual changes.' Do not run it when you are 'not finished' with the goal but still ending your turn. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 311e03d5..97944736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,31 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -31,6 +50,20 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -154,6 +187,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "append-only-bytes" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" + [[package]] name = "approx" version = "0.5.1" @@ -177,6 +216,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -210,6 +252,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" @@ -508,6 +556,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "async_zip" version = "0.0.17" @@ -542,6 +601,29 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomicwrites" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef1bb8d1b645fe38d51dfc331d720fb5fc2c94b440c76cc79c80ff265ca33e3" +dependencies = [ + "rustix 0.38.44", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -597,6 +679,38 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand 2.4.1", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base62" version = "2.2.4" @@ -609,6 +723,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "beef" version = "0.5.2" @@ -694,6 +814,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitpacking" version = "0.9.3" @@ -868,7 +997,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -970,6 +1099,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "bzip2" @@ -1119,6 +1251,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.1", ] @@ -1342,6 +1475,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "comemo" version = "0.5.1" @@ -1441,6 +1584,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1759,6 +1921,15 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1768,14 +1939,67 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1791,17 +2015,54 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.117", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1820,6 +2081,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1830,19 +2102,96 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1963,6 +2312,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", + "block2", + "libc", "objc2", ] @@ -1987,13 +2338,24 @@ dependencies = [ ] [[package]] -name = "docx-rs" -version = "0.4.20" +name = "dlopen2" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ - "base64", - "image", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "docx-rs" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" +dependencies = [ + "base64", + "image", "quick-xml 0.36.2", "serde", "serde_json", @@ -2097,6 +2459,33 @@ dependencies = [ "serde", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -2159,6 +2548,47 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "ensure-cov" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -2199,6 +2629,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2460,6 +2902,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "file-id" version = "0.2.3" @@ -2496,6 +2944,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2530,10 +2984,14 @@ name = "flowstate" version = "0.1.0" dependencies = [ "anyhow", + "async-channel 2.5.0", + "atomicwrites", + "blake3", "clap", "cosmic-text", "dirs 6.0.0", "fff-search", + "flowstate-collab", "flowstate-document", "flowstate-docx", "flowstate-flow", @@ -2543,6 +3001,7 @@ dependencies = [ "gpui-component-assets", "gpui-symbols", "hotpath", + "loro", "memchr", "mimalloc", "rustc-hash 2.1.2", @@ -2550,6 +3009,34 @@ dependencies = [ "serde_json", "tempfile", "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "flowstate-collab" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "flowstate-document", + "gpui-flowtext", + "iroh", + "iroh-gossip", + "iroh-tickets", + "loro", + "n0-future", + "postcard", + "proptest", + "rand 0.9.4", + "rustc-hash 2.1.2", + "serde", + "tempfile", + "tokio", + "tracing", + "twox-hash", "uuid", ] @@ -2557,9 +3044,18 @@ dependencies = [ name = "flowstate-document" version = "0.1.0" dependencies = [ + "atomicwrites", + "blake3", "gpui", "gpui-flowtext", + "imagesize", + "loro", + "postcard", "rustc-hash 2.1.2", + "serde", + "tempfile", + "uuid", + "zstd", ] [[package]] @@ -2571,6 +3067,7 @@ dependencies = [ "flowstate-document", "gpui", "hotpath", + "imagesize", "lopdf", "quick-xml 0.36.2", "rdocx", @@ -2590,6 +3087,7 @@ dependencies = [ "rustc-hash 2.1.2", "serde", "serde_json", + "tracing", "uuid", ] @@ -2624,7 +3122,7 @@ dependencies = [ "futures-core", "futures-sink", "nanorand", - "spin", + "spin 0.9.8", ] [[package]] @@ -2797,6 +3295,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2807,6 +3318,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite 2.6.1", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -2907,6 +3431,21 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2917,6 +3456,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-btree" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" +dependencies = [ + "arref", + "heapless 0.9.3", + "itertools 0.11.0", + "loro-thunderdome", + "proc-macro2", + "rustc-hash 2.1.2", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2970,6 +3523,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.13.3" @@ -2990,6 +3553,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "git2" version = "0.20.4" @@ -3103,6 +3672,7 @@ dependencies = [ "as-raw-xcb-connection", "ashpd 0.11.1", "async-task", + "backtrace", "bindgen", "blade-graphics", "blade-macros", @@ -3121,7 +3691,7 @@ dependencies = [ "core-video", "cosmic-text", "ctor", - "derive_more", + "derive_more 0.99.20", "embed-resource", "etagere", "filedescriptor", @@ -3179,7 +3749,7 @@ dependencies = [ "wayland-protocols-plasma", "windows 0.61.3", "windows-core 0.61.2", - "windows-numerics", + "windows-numerics 0.2.0", "windows-registry 0.5.3", "x11-clipboard", "x11rb", @@ -3253,6 +3823,7 @@ dependencies = [ name = "gpui-flowtext" version = "0.1.0" dependencies = [ + "blake3", "cosmic-text", "crop", "gpui", @@ -3330,7 +3901,7 @@ dependencies = [ "async-compression", "async-fs", "bytes", - "derive_more", + "derive_more 0.99.20", "futures", "gpui_util", "http", @@ -3417,12 +3988,15 @@ dependencies = [ "dunce", "futures", "futures-lite 1.13.0", + "git2", "globset", "gpui_collections", + "gpui_util_macros", "itertools 0.14.0", "libc", "log", "nix 0.29.0", + "rand 0.9.4", "regex", "rust-embed", "schemars", @@ -3496,6 +4070,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3537,6 +4120,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -3657,10 +4242,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", - "spin", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", "stable_deref_trait", ] @@ -3732,6 +4337,83 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hidden-trait" version = "0.1.2" @@ -3920,6 +4602,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3949,13 +4632,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -4235,6 +4921,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -4257,13 +4949,33 @@ dependencies = [ ] [[package]] -name = "ignore" -version = "0.4.26" +name = "igd-next" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" dependencies = [ - "crossbeam-deque", - "globset", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", "log", "memchr", "regex-automata", @@ -4272,6 +4984,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "image" version = "0.25.10" @@ -4303,7 +5030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -4421,11 +5148,269 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry 0.6.1", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef865dc2d11a19fe670ff217b68ffc3b511bddf473dc3a3e120090b9f691803" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum 0.28.0", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots", +] + +[[package]] +name = "iroh-base" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af93d67701c00c504982154569192ad384738c0450ba1196930314b955100552" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more 2.1.1", + "digest 0.11.3", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de4112c91eb64094d77df9d3112606dcf7ff216421afccd2dc762fda5a7b2879" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more 2.1.1", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "rand 0.10.1", + "reqwest", + "rustls", + "simple-dns", + "strum 0.28.0", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-gossip" +version = "0.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5cada641f84a2ff3d1acbd43e16490cf3b2a4f675e580c5c2caa5d68ee63b5" +dependencies = [ + "blake3", + "bytes", + "constant_time_eq", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek", + "futures-concurrency", + "hex", + "indexmap", + "iroh", + "iroh-base", + "iroh-metrics", + "irpc", + "n0-error", + "n0-future", + "postcard", + "rand 0.10.1", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70030b9e71c1183bd4f88fbdbebfa1af2a5be549dd6f20a1e8ac3cd0202ee9d" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.0", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum 0.28.0", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d4051ac9825cd834d88377dab71c1d9d483b65e9740029b2d624cc17ac58d" +dependencies = [ + "data-encoding", + "derive_more 2.1.1", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "irpc" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05799eb70acd04843c327ef939233ccf80f607d30e9ca94857ac7f3d8f18b46" +dependencies = [ + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445d81dbc1eed4dab6379bf7f97d12ac28ce8e6f3f7d6660c9f333b7b5d8d03b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "is-docker" @@ -4472,6 +5457,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -4537,6 +5531,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -4647,7 +5690,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -4665,6 +5708,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -4879,6 +5928,21 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + [[package]] name = "loop9" version = "0.1.5" @@ -4920,6 +5984,144 @@ dependencies = [ "weezl", ] +[[package]] +name = "loro" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd1b63cd2f0cf66acbbb3191c41feaff03c94c3f6c6bb1e61d2ab3062c301c7" +dependencies = [ + "enum-as-inner 0.6.1", + "generic-btree", + "loro-common", + "loro-delta", + "loro-internal", + "loro-kv-store", + "rustc-hash 2.1.2", + "tracing", +] + +[[package]] +name = "loro-common" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51042936156a1d537df8a38938ec8ed24b45de14530af933c1d02de39c9e056" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.1", + "leb128", + "loro-rle", + "nonmax", + "rustc-hash 2.1.2", + "serde", + "serde_columnar", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "loro-delta" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f381e7fb5d4bf5605b4f4a9241f3adf61403ded978d757552004c8302cccbb" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] + +[[package]] +name = "loro-internal" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252a84196402ac55a7dd064af87a747a430fc176ec744d5bb99f4ed92e639fa8" +dependencies = [ + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.1", + "enum_dispatch", + "generic-btree", + "getrandom 0.2.17", + "im", + "itertools 0.12.1", + "leb128", + "loom", + "loro-common", + "loro-delta", + "loro-kv-store", + "loro-rle", + "loro_fractional_index", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "parking_lot", + "pest", + "pest_derive", + "postcard", + "pretty_assertions", + "rand 0.8.6", + "rustc-hash 2.1.2", + "serde", + "serde_columnar", + "serde_json", + "smallvec", + "thiserror 1.0.69", + "thread_local", + "tracing", + "wasm-bindgen", + "xxhash-rust", +] + +[[package]] +name = "loro-kv-store" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950cc8bcc64dcff536e949fbc56ccc3d0759646fa441c7f76b3cd7c3eafa2096" +dependencies = [ + "bytes", + "ensure-cov", + "loro-common", + "lz4_flex 0.11.6", + "once_cell", + "quick_cache", + "rustc-hash 2.1.2", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "loro-rle" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" +dependencies = [ + "append-only-bytes", + "num", + "smallvec", +] + +[[package]] +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "loro_fractional_index" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca7180674d0273ddf37049a5efcde4547fd5330d24abb7519bb9d9eb6780d5b" +dependencies = [ + "once_cell", + "rand 0.8.6", + "serde", +] + [[package]] name = "lru" version = "0.16.4" @@ -4929,6 +6131,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -5000,6 +6211,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + [[package]] name = "lz4_flex" version = "0.13.1" @@ -5008,11 +6228,11 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "lzma-rust2" -version = "0.16.4" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" dependencies = [ - "sha2 0.11.0", + "sha2 0.10.9", ] [[package]] @@ -5021,6 +6241,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -5094,6 +6320,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "measure_time" version = "0.9.0" @@ -5270,6 +6502,23 @@ dependencies = [ "which 5.0.0", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -5297,53 +6546,233 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" [[package]] -name = "mutate_once" -version = "0.1.2" +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "n0-error" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +dependencies = [ + "anyhow", + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more 2.1.1", + "futures-buffered", + "futures-lite 2.6.1", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52" +dependencies = [ + "derive_more 2.1.1", + "n0-error", + "n0-future", +] + +[[package]] +name = "naga" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "strum 0.26.3", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "neo_frizbee" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728e0731ad3a0083b9f72a82df72c298641ba970d4e308e0897ec4c89212c31d" +dependencies = [ + "itertools 0.14.0", + "raw-cpuid", +] + +[[package]] +name = "netdev" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] [[package]] -name = "naga" -version = "25.0.1" +name = "netlink-proto" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.13.0", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.15.5", - "hexf-parse", - "indexmap", + "bytes", + "futures", "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "strum 0.26.3", + "netlink-packet-core", + "netlink-sys", "thiserror 2.0.18", - "unicode-ident", ] [[package]] -name = "nanorand" -version = "0.7.0" +name = "netlink-sys" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ - "getrandom 0.2.17", + "bytes", + "futures-util", + "libc", + "log", + "tokio", ] [[package]] -name = "neo_frizbee" -version = "0.10.2" +name = "netwatch" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728e0731ad3a0083b9f72a82df72c298641ba970d4e308e0897ec4c89212c31d" +checksum = "2071e0c2b5b229622c459096b84f1ad51afa150cdeeefdad491ef3704e581d91" dependencies = [ - "itertools 0.14.0", - "raw-cpuid", + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.30.0", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", ] [[package]] @@ -5415,12 +6844,80 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "noop_proc_macro" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "noq" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198b99fc085a5db1f7d259edb5ede8311e59f28cdd2687920b4313613d21a73f" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab0ac774795ce1e42a7e61266e71f3be8110210630441169ac8dda403dd23f1" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more 2.1.1", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3c1520eacd33fd6b009e2e70116b05508ade51db5e0d315ff8bf6b702148c2b" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "normpath" version = "1.5.1" @@ -5641,6 +7138,37 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -5691,7 +7219,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -5705,6 +7235,20 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -5718,6 +7262,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -5747,6 +7293,41 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5801,6 +7382,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -5849,6 +7434,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.5" @@ -5934,6 +7525,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parking" version = "2.2.1" @@ -6032,12 +7633,74 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.11.3" @@ -6182,6 +7845,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -6256,19 +7929,63 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portmapper" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64959cbabf952c8ffcbaea13745308508f1f825922f4068353f3de08d42cf214" dependencies = [ - "portable-atomic", + "base64", + "bytes", + "derive_more 2.1.1", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", ] [[package]] @@ -6297,10 +8014,22 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", + "postcard-derive", "serde", ] +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -6337,6 +8066,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -6425,6 +8175,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax 0.8.10", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "psm" version = "0.1.31" @@ -6491,6 +8260,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -6544,6 +8319,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" +dependencies = [ + "ahash 0.8.12", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -6702,6 +8489,33 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -6752,7 +8566,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -7014,6 +8828,49 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "resvg" version = "0.45.1" @@ -7320,6 +9177,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -7359,6 +9217,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -7376,6 +9261,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.14.1" @@ -7525,6 +9422,16 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -7537,6 +9444,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -7547,6 +9460,41 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_columnar" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" +dependencies = [ + "itertools 0.11.0", + "postcard", + "serde", + "serde_columnar_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_columnar_derive" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7668,6 +9616,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sfsymbols" version = "0.2.1" @@ -7753,12 +9711,28 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -7774,6 +9748,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -7789,6 +9772,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "sketches-ddsketch" version = "0.4.0" @@ -7848,6 +9841,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol" @@ -7882,6 +9878,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "spin" version = "0.9.8" @@ -7891,6 +9904,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -7900,6 +9919,16 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -8039,6 +10068,15 @@ dependencies = [ "strum_macros 0.27.2", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + [[package]] name = "strum_macros" version = "0.26.4" @@ -8064,6 +10102,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -8299,6 +10349,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -8321,6 +10382,12 @@ dependencies = [ "slotmap", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take-until" version = "0.2.0" @@ -8351,8 +10418,8 @@ dependencies = [ "itertools 0.14.0", "levenshtein_automata", "log", - "lru", - "lz4_flex", + "lru 0.16.4", + "lz4_flex 0.13.1", "measure_time", "memmap2", "once_cell", @@ -8607,7 +10674,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.5.15", ] @@ -8621,7 +10688,9 @@ dependencies = [ "deranged", "itoa", "js-sys", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -8738,10 +10807,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -8764,6 +10846,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -8773,10 +10867,34 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.23" @@ -8884,6 +11002,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -8902,6 +11038,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -9049,6 +11186,15 @@ dependencies = [ "syntect", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.4", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -9365,6 +11511,12 @@ dependencies = [ "unicode-math-class", ] +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -9376,6 +11528,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-langid" version = "0.9.6" @@ -9531,6 +11689,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -9685,6 +11853,43 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version_check" version = "0.9.5" @@ -9711,6 +11916,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -9850,13 +12064,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmi" version = "0.51.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff" dependencies = [ - "spin", + "spin 0.9.8", "wasmi_collections", "wasmi_core", "wasmi_ir", @@ -10025,6 +12252,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -10056,6 +12301,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -10103,11 +12354,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -10120,7 +12383,7 @@ dependencies = [ "rayon", "thiserror 2.0.18", "windows 0.61.3", - "windows-future", + "windows-future 0.2.1", ] [[package]] @@ -10132,6 +12395,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -10178,7 +12450,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -10247,6 +12530,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.4.0" @@ -10269,6 +12562,17 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -10425,6 +12729,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -10700,6 +13013,21 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "workspace-hack" version = "0.1.0" @@ -10731,6 +13059,25 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" @@ -10843,6 +13190,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xml5ever" version = "0.18.1" @@ -10860,6 +13213,15 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -10887,6 +13249,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.2.1" @@ -11086,7 +13454,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tokio-rustls", "tokio-socks", @@ -11096,7 +13464,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "windows-registry 0.4.0", ] diff --git a/Cargo.toml b/Cargo.toml index 7cdeca66..2cf0e4d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,10 @@ publish = false [workspace.dependencies] anyhow = "1.0.102" +async-channel = "2" clap = { version = "4.5", features = ["derive"] } cosmic-text = { version = "0.14.2", default-features = false, features = [ + "std", "shape-run-cache", ] } crop = "0.4.3" @@ -23,6 +25,7 @@ docxide-pdf = { version = "0.15.1", default-features = false, git = "https://git fff-search = "0.8.2-nightly.ffb39a8" flowstate-document = { version = "0.1.0", path = "crates/flowstate-document" } flowstate-docx = { version = "0.1.0", path = "crates/flowstate-docx" } +flowstate-collab = { version = "0.1.0", path = "crates/flowstate-collab" } flowstate-flow = { version = "0.1.0", path = "crates/flowstate-flow" } flowstate-tub = { version = "0.1.0", path = "crates/flowstate-tub" } gpui-flowtext = { version = "0.1.0", path = "crates/gpui-flowtext" } @@ -33,11 +36,19 @@ gpui-symbols = { version = "0.6.1", features = ["component"] } hotpath = "0.16.1" ignore = "0.4.25" imagesize = "0.13" +iroh = "1.0.0-rc.1" +iroh-gossip = "0.100" +iroh-tickets = "1.0.0-rc.1" lopdf = "0.40.0" +loro = "1.13" mimalloc = "0.1.52" mitex = "0.2" notify = "8.2.0" +n0-future = "0.3" +postcard = { version = "1", features = ["use-std"] } +proptest = "1" quick-xml = "0.36.2" +rand = "0.9" rdocx = "0.1.2" rdocx-opc = "0.1.2" rdocx-oxml = "0.1.2" @@ -48,7 +59,12 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" tantivy = "0.26.1" tempfile = "3" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } toml = "1.1.2" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +twox-hash = "2" typst = "0.14" typst-assets = "0.14" typst-library = "0.14" diff --git a/README.md b/README.md index 441b4088..4c37fbd0 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,29 @@ The performant, narrow text editor for debaters. To build Flowstate, you need to have [Cargo](https://doc.rust-lang.org/cargo/) installed. Run `cargo run --package flowstate --release` to build and run. +# Logging +Flowstate writes a daily-rotated log file (`flowstate.log`) to its data directory. +By default it logs **only `error`-level events** so it stays quiet; raise the +verbosity with the environment variables below. + +| Variable | Values | Effect | +|---|---|---| +| `FLOWSTATE_LOG_LEVEL` | `error` \| `warn` \| `info` \| `debug` \| `trace` | Sets the level for Flowstate's own crates (`flowstate`, `flowstate_collab`, `gpui_flowtext`); dependencies stay at `error`. Simplest knob. | +| `FLOWSTATE_LOG` | an [`EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) directive | Full control, including dependency crates, e.g. `flowstate_collab=trace,flowstate::collab=debug`. Takes precedence over `RUST_LOG` and `FLOWSTATE_LOG_LEVEL`. | +| `RUST_LOG` | an `EnvFilter` directive | Same as `FLOWSTATE_LOG` but the standard variable; used if `FLOWSTATE_LOG` is unset. | +| `FLOWSTATE_LOG_STDOUT` | `1` \| `true` \| `yes` \| `on` | Also mirror logs to stdout (in addition to the file). | +| `FLOWSTATE_LOG_DIR` | a directory path | Override where the log file is written. | + +Precedence for the level/filter: `FLOWSTATE_LOG` > `RUST_LOG` > `FLOWSTATE_LOG_LEVEL` > default (`error`). + +Examples: +```sh +# Verbose Flowstate logs, mirrored to the terminal: +FLOWSTATE_LOG_LEVEL=debug FLOWSTATE_LOG_STDOUT=1 cargo run --package flowstate + +# Fine-grained control over specific targets: +FLOWSTATE_LOG="flowstate_collab=trace,flowstate::collab=debug" cargo run --package flowstate +``` + # License Flowstate is licensed under the GNU Affero General Public License v3.0. diff --git a/adjustmentplan.md b/adjustmentplan.md new file mode 100644 index 00000000..37c511a5 --- /dev/null +++ b/adjustmentplan.md @@ -0,0 +1,1283 @@ +# Flowstate Loro-Native Document Architecture Specification + +## 1. Non-Negotiable Objective + +Flowstate's document architecture must be rebuilt around Loro as the canonical document substrate. + +The target architecture is not "a Flowstate document mirrored into Loro." It is "a Loro document rendered and edited by Flowstate." + +Do not optimize this plan for implementation lift, prototype speed, or backwards compatibility with the current development-only `.db8` format. The target is the production architecture Flowstate should actually keep. The ABSOLUTE PLATONIC IDEAL. + +The renderer, layout engine, UI command layer, DOCX/PDF/export tools, search tools, and caches may remain Flowstate-specific. The document state, durable history, undo/redo basis, collaboration state, revision timeline, and save format must be Loro-native. + +The goal is a single architecture that supports: + +* local editing +* collaborative editing +* durable operation history +* time travel +* branch/fork restore flows +* per-peer undo/redo +* CRDT-safe concurrent editing +* structured rich documents +* filesystem persistence +* DOCX import and export compatibility +* PDF/export generation from snapshots +* deterministic rendering from canonical state +* within-document and tub search from Loro-derived projections + +The old development `.db8` document format is not a compatibility target. DOCX is still a first-class external import/export format. + +## 1A. Locked Clarifications Before Implementation + +These decisions are part of the architecture, not optional implementation notes. + +The user-facing Flowstate document extension remains `.db8`. That extension is brand identity only. It does not imply compatibility with the old development `.db8` serializer, old `gpui-flowtext::Document` persistence, or any previous final-state native binary format. From this architecture onward, a `.db8` file is a Loro-native Flowstate package as specified in this document. + +There is no old `.db8` importer, compatibility reader, migration path, fallback decoder, or dual-format open path. The previous development `.db8` format should be treated as if it never existed. Code that exists only to read, write, recover, index, export, or interoperate with the old final-state `.db8` model must be removed or rewritten onto the Loro-native package model. + +The implementation must use the Rust `loro` crate APIs as authoritative. `loroapi.md` may be used as conceptual API background, but it documents the JavaScript/TypeScript binding and must not be treated as proof that a Rust API with the same name or capability exists. Before relying on a Loro feature, verify it against the Rust crate in use. + +Rust Loro currently exposes rich text as text plus marks/cursors, and nested containers through maps/lists/movable lists. It does not expose a Rust-native rich-text embed/container insertion API for placing child containers directly inside `LoroText`. Therefore object and structural block ordering is locked to the object replacement character strategy: use `U+FFFC` (`\u{FFFC}`) placeholders inside flow `LoroText`, and store the corresponding structured object metadata in Loro maps such as `blocks_by_id`. The placeholder is the CRDT-ordered anchor. The block map is the editable durable object record. + +Tables are fully in scope for this architecture pass. They must be implemented as structured Loro objects with row identity, column identity, cell identity, spans, nested table support, and independent CRDT text flows per cell. A table may not be stored as an opaque binary payload or rewritten wholesale for normal edits. + +Named revisions are time-travellable snapshot/frontier points. A named revision must preserve the ability to render, open, and fork that point in document history. It does not need to preserve every individual low-level operation that occurred before that named point. History compaction may squash unnamed low-level update history once the retained snapshots/frontiers needed for named revisions and product checkpoints remain restorable. + +PDF source embedding must embed only the new Loro-native `.db8` package bytes. It must not embed the old development `.db8` payload, and it must not support both old and new source payloads. + +## 2. Canonical Model + +There is exactly one canonical document model: the `LoroDoc`. + +Everything else is derived. + +Flowstate may maintain a `DocumentProjection`, layout cache, paragraph index, glyph cache, hit-test map, section cache, pagination cache, asset cache, render tree, search index, preview cache, and export projection. None of those are authoritative. They can be discarded and rebuilt from the Loro document plus the asset store. + +The following must not remain canonical: + +* native final-state `.db8` serialization +* `gpui-flowtext::Document` as persistent source of truth +* Flowstate operation logs as persistent source of truth +* patch streams as document truth +* local/remote mutation paths that reconcile two document models +* binding layers whose job is to keep Flowstate state and Loro state synchronized +* object payload blobs that hide structured editable content from Loro + +The canonical pipeline is: + +```text +Local command + -> resolve command against current DocumentProjection + -> send semantic command to CRDT runtime + -> mutate LoroDoc in grouped Loro transaction/change + -> permanent Loro subscription emits document event/update + -> CRDT runtime updates or invalidates DocumentProjection + -> UI receives projection diff/snapshot + -> render + -> persist Loro update + -> sync Loro update + +Remote update + -> CRDT runtime imports bytes into LoroDoc + -> import status triggers immediate anti-entropy if dependencies are missing + -> permanent Loro subscription emits document event/update + -> CRDT runtime updates or invalidates DocumentProjection + -> UI receives projection diff/snapshot + -> render + -> persist accepted update +``` + +There is no dual-write model. + +## 3. Runtime Ownership + +Flowstate must use a dedicated CRDT runtime that owns each live `LoroDoc`. + +The UI thread must not own or mutate the canonical `LoroDoc`. The UI owns the current immutable or copy-on-write projection state needed for rendering, layout, hit testing, and command construction. + +The CRDT runtime: + +* owns the `LoroDoc` +* owns the permanent Loro subscriptions +* owns the Loro `UndoManager` +* owns import/export/update persistence coordination +* receives semantic editor commands over channels +* returns projection diffs, projection snapshots, selection updates, asset availability changes, and status events +* batches projection work by Loro commit/frontier +* isolates CRDT work from UI frame timing + +The preferred shape is: + +```text +UI thread + DocumentProjection + LayoutEngine + Renderer + HitTest + Command construction + Presence rendering + +CRDT runtime thread/task + LoroDoc + UndoManager + Loro subscriptions + Persistence writer + Sync import/export + Projection builder/incremental projector + Asset manifest coordination +``` + +The runtime may use a thread, task, or actor implementation, but the ownership boundary is mandatory: canonical Loro mutation happens in one place. + +## 4. Command Layer + +The old Flowstate collaboration API and `CanonicalOperation` path must be retired and removed. + +Flowstate should keep semantic editor commands, but those commands must target the Loro-native schema directly. They are user intent, not a second canonical operation language. + +The command architecture is locked: + +> UI sends semantic commands; the CRDT runtime resolves them into Loro schema mutations. + +The UI must not construct low-level Loro schema mutations directly as the normal command path. Schema mutation authority lives in the CRDT runtime. + +Preserving the old `CanonicalOperation` API as a durable or canonical path is explicitly rejected. + +```text +UI input + -> hit test/projection coordinate + -> semantic command + -> CRDT runtime validates against current frontier + -> Loro schema mutation(s) + -> grouped commit + -> projection diff +``` + +Examples: + +```text +insert_text(flow_id, cursor, text, style_state) +split_paragraph(flow_id, cursor, inherited_attrs) +join_paragraphs(flow_id, boundary_id) +set_run_semantic_style(flow_id, range, style_id) +set_highlight_style(flow_id, range, highlight_id) +insert_table(anchor, rows, columns) +insert_image(anchor, asset_ref, attrs) +edit_image_alt_text(image_id, text_command) +edit_equation_source(equation_id, text_command) +move_block(block_id, target_cursor) +undo() +redo() +open_revision(frontier) +fork_revision(frontier) +``` + +## 5. Projection And Editor Model + +The ideal target may substantially replace or rearchitect `gpui-flowtext::Document`. + +The editor/render model must replace the current mutable `gpui-flowtext::Document` entirely. + +Keeping a type named `Document` while redefining it as projection-only is not the target. Keeping the current mutable `Document` plus Loro binding/reconciliation is rejected. + +`DocumentProjection` is a frontier-scoped view: + +```text +DocumentProjection + frontier + flows + paragraphs + blocks + sections + table_grids + inline_style_runs + object_metadata + asset_refs + search_units + cursor_position_maps + byte/grapheme/UTF-16 indexes for the projection only + dirty_ranges +``` + +The projection must be rebuildable from: + +```text +LoroDoc at frontier + asset manifest +``` + +The projection may cache: + +* paragraph boundaries +* paragraph IDs +* block order +* style runs +* table layout inputs +* glyph layout inputs +* pagination keys +* hit-test data +* search units +* export units + +Projection coordinates are not canonical. Canonical command positions should use Loro cursors wherever possible. Byte, grapheme, UTF-16, row, column, and paragraph indexes are local coordinates within a specific projection frontier. + +If projection encounters a malformed paragraph boundary without a paragraph-style mark, it should default that paragraph to Normal and immediately schedule a repair mutation in the CRDT runtime. Repair should modify the underlying Loro buffer so the document is safe before further p2p sharing propagates the malformed state. + +## 6. Canonical Text Model + +The document must not use one independent `LoroText` per paragraph as the primary body text model. + +That design breaks expected word-processor semantics for concurrent paragraph splits and inline edits. + +Example: + +```text +Initial: +ABC + +Alice inserts "y" between B and C. +Bob presses Enter between A and B. +``` + +The semantically correct result is: + +```text +A +ByC +``` + +Not: + +```text +Ay +BC +``` + +Therefore, paragraph content must live inside a continuous CRDT text sequence. + +The rule is: + +> Use one canonical `LoroText` per continuous text flow. + +A text flow is a sequence where inline edits and paragraph boundary changes must commute naturally. + +Independent text flows include: + +* main document body +* table cell contents +* image caption +* image alt text +* equation source +* footnote body, if footnotes are added +* endnote body, if endnotes are added +* header text flow +* footer text flow +* sidebar text flow, if sidebars are added + +Annotations, comments, bookmarks, and suggestion systems are out of scope for this plan and should not appear in the target schema. + +Paragraph styles apply to paragraph-bearing flows: body flows, table cell flows, caption flows, headers, footers, and similar prose flows. + +Equation source and image alt text flows do not have paragraph styles. They are plain/specialized text flows, not Flowstate paragraph flows. + +## 7. Paragraph Boundaries + +The preferred paragraph boundary representation is literal newline characters inside the flow `LoroText`. + +Rationale: + +* It matches ordinary text semantics. +* It keeps paragraph split/join as native text edits. +* It avoids private-use marker leakage into clipboard/search/export. +* It already matches the rough direction of the current root body text model. +* It makes body text easier to inspect, search, import, and export. + +Each text flow should begin with a persistent sentinel newline character. + +That sentinel newline is not rendered as a visible blank line and is not user-deletable. It exists so every editable flow always has a paragraph boundary character before the first user-visible paragraph. + +Paragraphs are signified by the boundary character that sets them off: + +```text +\nParagraph one\nParagraph two\nParagraph three +^ ^ ^ +sentinel boundary boundary +``` + +The initial sentinel boundary carries the first paragraph's paragraph-style mark. Each later paragraph's style is carried by the boundary newline immediately before that paragraph. This is the predecessor-boundary model. + +The plan rejects custom paragraph marker characters unless a later Loro limitation makes newline anchoring insufficient. + +Paragraph identity metadata must still be Loro-native and durable. Newlines alone are enough for paragraph boundaries, but paragraph IDs and stable paragraph anchors still need Loro-native representation. + +Each paragraph is represented by identity metadata anchored to its start boundary: + +```text +ParagraphMap + id: ParagraphId + flow_id: FlowId + start_cursor: Loro cursor at paragraph start / preceding boundary + boundary_cursor: Loro cursor for paragraph-ending newline when available +``` + +Empty paragraphs are representable because an empty paragraph is a boundary interval. A blank line between two newline characters is a real paragraph and carries style on its predecessor boundary newline. + +Paragraph style storage is locked to Loro marks. + +Flowstate should use Loro's rich-text mark APIs for paragraph style. The canonical paragraph style is attached to paragraph boundary text, normally the newline immediately before the paragraph. This keeps Flowstate aligned with Loro's intended rich-text API instead of inventing a parallel paragraph-style substrate. + +The mark model must still define word-processor semantics explicitly: + +* Every flow starts with a non-rendered, non-deletable sentinel newline. +* The first visible paragraph gets its style from the sentinel newline's paragraph-style mark. +* Every later paragraph gets its style from the newline immediately before it. +* A blank paragraph between two newline characters gets its paragraph style from the predecessor newline. +* Splitting a paragraph in the middle makes both resulting paragraphs inherit the origin paragraph's style. +* Pressing Enter at the end of a styled paragraph creates a following blank paragraph with Normal style. +* Pressing Enter at the start of a styled paragraph creates a preceding blank paragraph that inherits the original paragraph's style; the original paragraph keeps its style. +* Joining paragraphs keeps the higher/earlier paragraph's style. +* Deleting across paragraphs keeps the higher/earlier surviving paragraph's style. +* Applying paragraph style at a collapsed caret applies to the current paragraph. +* Applying paragraph style to any selection applies to every paragraph touched by the selection, including partially touched first/last paragraphs. +* Rich paste preserves pasted paragraph styles unless pasted onto a styled paragraph in a way that semantically replaces that paragraph; in that replacement case, pasted paragraphs inherit the target paragraph's style. +* Plain-text paste with newlines inherits the insertion paragraph's style for all inserted paragraphs. + +Do not create a competing paragraph-style field in paragraph/block metadata. If a projection caches paragraph style, that cache is derived from Loro marks. + +Paragraph style uses one Loro mark key: + +```text +paragraph_style = +``` + +Paragraph style IDs are stable enum slots with holes reserved for future styles. User-facing labels can change freely in the app/theme without changing document semantics. The document stores style identity, not display names and not appearance attributes. + +Paragraph-style marks should use the least-sticky Loro mark expansion setting that supports boundary-only paragraph marks. Normal paragraph-style inheritance should not rely on automatic mark expansion. Commands such as Enter, split paragraph, paste, import, and repair must explicitly set paragraph-style marks on the affected boundary newlines. + +If two peers concurrently set different paragraph-style enum values on the same boundary under the same `paragraph_style` key, Flowstate accepts Loro's deterministic visible winner. Projection should use the value Loro exposes as current. This is normal CRDT conflict resolution, not corruption. + +Other paragraph-level properties are out of scope unless Flowstate later introduces them as real editor features. If future paragraph-level properties cannot be represented cleanly as Loro marks, they need a separate design decision at that time. + +The renderer strips no custom paragraph marker. It renders newline-delimited paragraphs from the projection. + +## 8. Flow Structure + +Each text flow is stored as: + +```text +FlowMap + id: FlowId + kind: "body" | "table_cell" | "caption" | "alt_text" | "equation_source" | "header" | "footer" | ... + text: LoroText + attrs: LoroMap +``` + +Flow maps are reachable from: + +```text +RootMap + flows_by_id: LoroMap +``` + +A flow may contain: + +* normal Unicode text +* newline paragraph boundaries +* object/embed placeholders when the flow supports embedded objects + +Paragraphs are ranges in a flow. Paragraph split inserts `\n`. Paragraph join removes `\n`. Inline insert inserts into the same flow text. + +Nested flows remain in Loro history even after the current document frontier no longer reaches them. They are not deleted from history merely because a block was deleted. Package-level asset/cache garbage collection may remove unreachable binary assets according to retention policy, but Loro document history remains history unless a deliberate lineage-compaction/export operation creates a new document lineage. + +## 9. Block And Object Ordering + +Block order is canonical in the text flow itself. + +The main body flow is the canonical ordered sequence for body paragraphs and body objects. A separate ordered block list must not become a second source of truth. + +Object blocks are represented by object placeholders in a flow. Their editable metadata lives in a block registry: + +```text +RootMap + blocks_by_id: LoroMap +``` + +Block map: + +```text +BlockMap + id: BlockId + kind: "paragraph" | "image" | "equation" | "table" | "divider" | ... + flow_id: FlowId + anchor_cursor: Loro cursor for position in parent flow + attrs: LoroMap + nested_refs: LoroMap +``` + +Paragraph blocks are metadata records over newline-delimited flow ranges. + +Object blocks are metadata records anchored to object placeholders in the flow. + +Images and equations do not have paragraph styles. + +Tables have independent table styling. Table styling is not inherited from surrounding paragraphs. + +Object placeholders use the Unicode object replacement character `U+FFFC` (`\u{FFFC}`) in the parent flow `LoroText`, plus a block ID mapping in `blocks_by_id`. This is locked because the Rust `loro` crate does not currently expose a native rich-text embed/container insertion API for placing child containers directly inside `LoroText`. + +Object placeholder characters are not user-authored document text. They are structural anchors. Clipboard, search, DOCX export, PDF export, plain-text export, and visible text rendering must project them as the corresponding object, not leak the raw replacement character as ordinary content. + +All durable IDs must be stored in Loro. Projection-generated IDs must not become canonical. + +## 10. Semantic Styling + +Flowstate does not allow arbitrary user-authored formatting as the canonical editing model. + +Inline styling must remain semantic-bound. Loro marks should store style identities and booleans from Flowstate's semantic style system, not freeform font/color/CSS values. + +Examples: + +```text +run_semantic_style_id +highlight_style_id +direct_underline +strikethrough +emphasis_style_id, if introduced +``` + +Paragraph style is a Loro mark on paragraph boundary text: + +```text +paragraph_style_id +``` + +The exact style model may continue to use overlapping semantic categories, as Flowstate does today: + +* paragraph style IDs stored as Loro paragraph-boundary marks +* run semantic style IDs +* highlight style IDs +* underline flag/style +* strikethrough flag/style +* future semantic style families + +The user-facing style catalog is app/theme data only. A style ID in Loro references a semantic enum slot; the appearance of that slot is resolved by the client theme/style catalog. + +DOCX import remains supported. Imported direct formatting must be interpreted into Flowstate's predefined semantic model using the existing Verbatim/heuristic import architecture, evolved as needed. + +DOCX import must not create new document-local styles. It maps recognized DOCX styles and formatting heuristics into Flowstate paragraph style slots, run semantic style slots, highlight slots, underline, and strikethrough. Unknown DOCX formatting may be reported or dropped, but it must not expand the live document's semantic style universe. + +Imported formatting should not turn the live editor into a freeform arbitrary formatter. + +URLs are ordinary document text. This plan does not model links as annotations or comments. + +Comments, annotations, bookmarks, and suggestions are out of scope. + +## 11. Sections And Page Structure + +Sections are structural metadata anchored to body flow positions. + +```text +sections_by_id: LoroMap +section_order: derived from body flow anchors unless later proven insufficient + +SectionMap + id + start_cursor + attrs: + page_size + margins + columns + header_flow_id + footer_flow_id + page_numbering + orientation +``` + +A section boundary should be a CRDT-stable position in the body flow. + +Headers and footers are independent text flows. + +Section/order metadata must not duplicate paragraph ordering. The body flow remains canonical for body order. + +Heading and outline computation reads paragraph style exclusively from paragraph-boundary marks. A paragraph participates in outline/section projection when its semantic paragraph style slot maps to a heading/section role in the app/theme style system. + +## 12. Tables + +Tables must be structurally CRDT-native. + +A table must not be stored as a single binary blob whose entire payload is rewritten for edits. + +The target table schema must support: + +* row identity +* column identity +* row insertion/deletion/movement +* column insertion/deletion/movement +* cell identity +* merged cells +* row spans +* column spans +* nested tables +* independent rich text flows per cell +* concurrent edits in different cells without conflict +* concurrent structural edits represented by Loro containers + +Preferred table schema: + +```text +TableBlock + id: BlockId + kind: "table" + anchor_cursor + attrs: TableAttrs + row_order: LoroMovableList + rows_by_id: LoroMap + column_order: LoroMovableList + columns_by_id: LoroMap + cells_by_id: LoroMap + +RowMap + id: RowId + attrs: RowAttrs + +ColumnMap + id: ColumnId + attrs: ColumnAttrs + +CellMap + id: CellId + row_id: RowId + column_id: ColumnId + row_span: u32 + column_span: u32 + attrs: CellAttrs + flow_id: FlowId + nested_table_ids: LoroMovableList +``` + +Each table cell owns an independent text flow. + +Inside a cell flow, paragraphs follow the same rule as the main body: one continuous `LoroText` per cell flow with newline paragraph boundaries. + +Use `LoroMovableList` for rows and columns. Tables are ordered structures with semantic movement. + +Do not use `LoroTree` as the main table representation unless arbitrary recursive tree moves become required. Tables are ordered row/column grids with cell flows, not general trees. + +## 13. Equations + +Equation source must be stored as `LoroText`. + +Equation metadata should be stored in `LoroMap`. + +```text +EquationBlock + id: BlockId + kind: "equation" + anchor_cursor + source_flow_id + attrs: + syntax: "latex" | "mathml" | ... + display: "inline" | "block" + numbering + alignment +``` + +If two users edit the equation source concurrently, the source should merge like text. + +Do not store equation source as a map string. + +Render output is not synchronized between peers. Each client renders equation source locally using Flowstate's live rendering system. Any rendered SVG/bitmap/layout result is a disposable client cache. + +Inline equations and block equations are both represented as object placeholders inside a parent text flow. The renderer decides whether the object is rendered inline or block-level from metadata. + +## 14. Images And Assets + +Images do not need a special image CRDT. + +Image metadata should be stored in `LoroMap`. Image bytes should be stored outside Loro in a content-addressed asset store. + +```text +ImageBlock + id: BlockId + kind: "image" + anchor_cursor + asset_id + content_hash + mime_type + byte_length + dimensions + crop + sizing_mode + alignment + alt_text_flow_id + caption_flow_id +``` + +Image metadata should use field-level map keys. Do not rewrite the whole image object for a metadata edit. + +Image caption and alt text must be CRDT-editable text flows. + +Asset bytes are stored in the package asset store, not in Loro. + +Asset content addressing must use BLAKE3 canonically. + +The canonical asset hash is a BLAKE3 digest. Short local cache keys may be derived for in-memory lookup, but they are never canonical and must not be used for integrity or package identity. + +Remote asset behavior: + +* CRDT updates may be accepted before referenced asset bytes arrive. +* The document must render a visible placeholder for missing assets. +* Missing assets should trigger prioritized asset pulls. +* Asset availability should participate in sync/anti-entropy status so peers can discover missing bytes, not just missing Loro ops. +* Local image insertion may commit Loro metadata before the full asset bytes are available. +* If asset bytes never arrive or remain incomplete, the document should retain the reference and render an explicit incomplete-asset placeholder. +* Remote metadata may arrive before bytes; that is acceptable and should render as a recoverable placeholder. + +## 15. Presence, Peers, And Author Metadata + +Presence is not document history. + +Remote carets, live selections, transient names/colors, and typing/live state belong in Loro ephemeral state or equivalent ephemeral sync state. + +Durable authorship metadata is separate. + +The schema should distinguish: + +```text +UserId + stable user-facing identity for blame/history/UX + +ReplicaId / PeerId + unique Loro editing replica identity + may differ for each device, app instance, tab, or session participant +``` + +The same user may have multiple devices. The same device or app instance may open multiple tabs into the same session. Those must be distinct Loro replicas even if they share one user-facing identity. + +Never set a shared or user-stable Loro peer ID. Loro peer IDs identify editing replicas and must be unique per active replica. + +The document may store durable author metadata keyed by user identity and/or by observed Loro peer IDs. Live roster/presence remains ephemeral. + +## 16. Selection, Cursors, And Affinity + +Canonical selection endpoints should use Loro cursors wherever possible. + +The selection model must include explicit affinity/gravity information: + +```text +SelectionEndpoint + cursor + affinity: before | after | neutral + visual_gravity + +Selection + anchor: SelectionEndpoint + head: SelectionEndpoint + direction +``` + +This is stronger than merely choosing `Side::Before` or `Side::After` at cursor creation. The editor needs to preserve the user's visual intent across: + +* concurrent inserts at the same position +* undo/redo cursor restoration +* bidi text +* object boundaries +* line wrapping +* selection extension +* collapsed caret movement + +Clarification: + +* A simple side choice says where the cursor sits relative to concurrent inserted content. +* A full affinity/gravity model stores why that side was chosen and how the caret should behave visually as text/layout changes. + +`Side::Middle` should not be hardcoded as the only selection side. It can remain available for genuinely neutral anchors, but normal caret/selection behavior should choose side from stored affinity. + +UndoManager cursor storage should save and restore selection cursors through Loro's cursor transformation support. + +## 17. Undo And Redo + +Undo/redo must be Loro-native for all documents, local or collaborative. + +Flowstate must not maintain a separate canonical undo/redo stack for document mutations. + +Undo is per-peer/per-replica local undo. If this user edits, then receives another peer's operation, pressing undo should reverse this user's edit, transformed over the remote change. It should not undo the other peer's operation. + +The CRDT runtime owns one Loro `UndoManager` per live document replica. + +Semantic undo units include: + +* typed word or typing burst +* paragraph split +* paste +* style change +* table row/column insert +* table structural edit +* object resize +* image insert +* equation edit +* section break insert + +Those undo units must correspond to grouped Loro changes. + +Use Loro-native mechanisms: + +* explicit commits for command boundaries +* Loro change merge interval for continuous local edits when appropriate +* UndoManager merge interval +* UndoManager group start/end for compound semantic commands +* UndoManager cursor onPush/onPop to restore selection + +Time travel is not undo. Time travel is checkout/fork/navigation over durable Loro frontiers. + +## 18. Time Travel, Revisions, And Restore + +The operation history is the durable basis for time travel. + +The document package should maintain a revision index: + +```text +Revision + id + title + timestamp + author/user + replica_id + frontier/version_vector + summary + thumbnail optional + parent revision optional +``` + +A revision records a named frontier into Loro history and/or a retained package snapshot sufficient to materialize that frontier. It is a time-travellable document point, not a promise to retain every low-level operation that happened before that point. + +Opening a revision checks out or materializes the Loro document at that frontier and renders the projection for that state. + +Restore behavior: + +* Opening a historical revision should open a new tab with that historical version. +* That tab should be dirty. +* Saving that tab should save as a branch/fork/new document according to product flow. +* Forking into a new branch/document must remain available as the preferred restore path. + +History compaction is mandatory. Full history forever is rejected. + +The compaction policy is locked: + +> Keep named revision snapshots/frontiers, compact unnamed update history. + +Compaction must preserve user-meaningful and product-meaningful revision frontiers as restorable/forkable points while squashing low-level update history that has no revision identity. A named revision must remain openable, renderable, and forkable after compaction, but the implementation may discard unnamed per-keystroke or per-command update detail beneath a retained revision snapshot/frontier. + +Natural automatic chunk points include: + +* app tab session boundaries +* save instances, especially explicit `Ctrl+S` +* named revision creation +* import completion +* major document-structure operations + +Each explicit save is a natural revision/checkpoint boundary. Future UX may allow users to name or preserve selected revision frontiers. Unnamed intra-session operation detail may be compacted once covered by retained snapshots/frontiers. Named revisions are snapshot/frontier preservation points, not permanent full-operation-log retention points. + +Creating a new explicit document lineage is not the preferred normal compaction strategy. + +## 19. Filesystem Package + +The user-facing artifact remains a single `.db8` document file. + +Internally, it must not be the old final-state native serializer. + +The `.db8` extension names the new Loro-native Flowstate package. It must not be used as a compatibility excuse for retaining the old development `.db8` format. Old `.db8` read/write/import/recovery/indexing code paths must be removed or rewritten to this package format. + +The ideal filesystem object is a Loro document package containing: + +```text +DocumentPackage + manifest + loro_snapshots + loro_update_segments + asset_store + revision_index + projection_cache + search_projection_cache + thumbnails + integrity_index +``` + +The package format is locked: + +> Flowstate uses a custom chunked binary container that is both Loro-native and Flowstate-native. + +Reasons: + +* single user-facing file +* append-friendly update storage +* Flowstate-controlled chunk layout and indexing +* efficient manifests and internal lookup indexes +* direct storage for snapshots, update segments, assets, thumbnails, and projection/search caches +* fast unopened-file indexing for tub search +* no dependency on a general SQL database file format for document storage +* room for Loro-aware and Flowstate-aware chunk types, compression, checksums, and partial reads + +The package must support external workspace/tub indexing. The package itself should expose enough projection/search metadata for Flowstate to index it quickly without fully opening the document or replaying Loro history. + +Logical chunk classes: + +```text +manifest chunk + package_format_version + loro_schema_version + document_id + latest_frontier + latest_snapshot_id + update_segment_index + asset_index + projection_cache_frontier + search_cache_frontier + created_at + modified_at + +loro snapshot chunks + snapshot_id + frontier + bytes + created_at + +loro update segment chunks + segment_id + from_frontier + to_frontier + bytes + checksum + created_at + +asset chunks + asset_id + content_hash + mime_type + byte_length + bytes + metadata + +revision index chunks + revision_id + frontier + title + summary + author_user_id + replica_id + created_at + +projection cache chunks + frontier + bytes + +search unit chunks + frontier + unit_id + unit_kind + heading_path + heading + body + insert_text + paragraph_start_cursor + paragraph_end_cursor +``` + +Only Loro snapshot/update data and asset bytes/references are canonical. + +Projection caches and search units are disposable. + +## 20. Read Path + +Opening a document: + +```text +open package +read manifest +load latest complete Loro snapshot +apply complete update segments after snapshot +construct LoroDoc in CRDT runtime +verify package format version +verify Loro schema version +verify document lineage/integrity +load projection cache if frontier matches +otherwise rebuild projection from Loro +load search cache if frontier matches +otherwise rebuild search units from projection/Loro +open renderer on DocumentProjection +``` + +The renderer never reads the old final-state document format as authoritative state. There is no legacy `.db8` reader in the target app. + +DOCX import is external import, not document storage. + +## 21. Write Path + +Saving is append-first and crash-safe. + +On each committed local Loro change: + +```text +receive local update bytes +append update segment in package transaction +record new frontier +update manifest +invalidate stale projection/search caches +schedule snapshot compaction if thresholds are crossed +``` + +Periodically, or when update history crosses thresholds: + +```text +export fresh Loro snapshot +write snapshot +mark older update segments compactable if revision policy permits +write projection cache for fast open +write search projection cache for tub/search +update manifest transactionally +``` + +A committed update segment must either be fully visible or ignored. The manifest must point only to complete, verified segments. + +## 22. Sync And Anti-Entropy + +Remote updates are imported into the CRDT runtime's `LoroDoc`. + +Remote updates must not be translated into Flowstate operations. + +Import status must be consumed. If import reports missing/pending dependencies, Flowstate should immediately trigger update pull/anti-entropy rather than waiting only for periodic digest. + +Periodic digest can remain as a fallback liveness mechanism, but the primary gap response should be actual Loro version-vector/update anti-entropy. + +Target path: + +```text +receive remote update bytes +import into LoroDoc +read ImportStatus +if pending/missing: request updates from peers immediately +Loro event reaches permanent subscription +project changed containers/ranges +persist accepted update +sync any newly generated update as needed +``` + +The only acceptable "patch" layer is a derived UI projection diff layer. There should be no `RemoteApplier` whose purpose is reconstructing canonical Flowstate document patches. + +## 23. Permanent Loro Subscription + +Temporary subscriptions around import/undo are not acceptable. + +The CRDT runtime must own a permanent subscription for each live document and filter/process events by origin, trigger, current frontier, and runtime epoch. + +This avoids relying on synchronous event timing during `import`, `checkout`, or `undo`. + +The subscription should feed: + +* projection invalidation +* local update publish +* persistence append +* search projection invalidation +* asset reachability checks +* undo/redo selection restoration +* revision/frontier status + +## 24. Projection Invalidation + +Projection invalidation is a core part of the architecture. + +Open design decision: + +| Option | Runtime perf | Correctness | Responsiveness | Complexity | P2P resilience | Summary | +|---|---:|---:|---:|---:|---:|---| +| A. Full projection rebuild after every Loro event | 3 | 10 | 3 | 4 | 8 | Simple and correct, but too slow for large documents and live collaboration. | +| B. Incremental projection from Loro diffs only | 9 | 7 | 9 | 9 | 7 | Fast but fragile if any event shape is missed. Needs strong fallback. | +| C. Batched frontier-based invalidation with incremental fast paths and full rebuild fallback | 9 | 10 | 9 | 8 | 10 | Preferred. Batch events per commit/frontier, apply known incremental invalidations, rebuild affected projection regions, and fall back to full rebuild on uncertainty. | +| D. Poll/diff snapshots after changes | 4 | 8 | 4 | 5 | 6 | Avoids event complexity but wastes Loro's event model and increases latency. | + +Current recommendation: Option C. + +The runtime should maintain projection indexes: + +* flow text boundary index +* paragraph range index +* paragraph metadata index +* block anchor index +* object placeholder index +* table row/column/cell index +* style interval index +* section anchor index +* asset reference index +* search unit index +* cursor resolution cache + +For each Loro event batch, the runtime should produce: + +```text +ProjectionInvalidation + frontier_before + frontier_after + changed_flows + changed_text_ranges + changed_blocks + changed_tables + changed_assets + changed_sections + rebuild_required flag +``` + +The UI receives stable projection diffs or a new projection snapshot. It never applies canonical document mutations itself. + +Full projection rebuild is an exceptional fallback, not an ordinary edit path. The runtime must make fallback use observable with structured logging/counters. Fallback should be treated as a performance bug if it occurs repeatedly during normal typing, formatting, table editing, image edits, or remote update import. + +The projection system should define explicit incremental paths for all expected common operations. Fallback is reserved for unknown event shapes, detected projection corruption, schema migration, recovery, or rare defensive repair. + +## 25. Search + +Within-document and tub search must be ported to the Loro-native architecture. + +The current tub architecture: + +* catalogs files in SQLite +* indexes searchable units in Tantivy +* extracts section/paragraph units from old final-state `.db8` +* hydrates previews from the old document model + +The new architecture should: + +* index Flowstate `.db8` Loro package files instead of old final-state `.db8` +* derive search units from `LoroDoc`/`DocumentProjection` +* preserve section-aware units such as block/tag/analytic/card/cite where those style semantics still exist +* store search unit cursors or paragraph IDs instead of raw old paragraph indexes as canonical references +* use raw paragraph indexes only as projection-local convenience values +* hydrate previews from the Loro projection or package search cache +* continue using Tantivy for workspace/tub full-text search unless a clearly superior crate replaces it + +Search cache data inside a package is disposable but should be saved by default for fast unopened-file indexing. The external tub index is also disposable. Both must be rebuildable from Loro package contents. + +## 26. DOCX, PDF, And Export + +DOCX import must be migrated onto the Loro-native model immediately. + +DOCX import path: + +```text +DOCX + -> interpreter/import heuristics + -> semantic Flowstate style model + -> Loro-native document creation + -> DocumentProjection + -> renderer +``` + +DOCX export, PDF export, and other output formats are generated from a Loro snapshot/frontier projection. + +Exports should not expose or require document history. Saving a Flowstate document saves the Loro package with history; exporting produces a final-state external artifact. + +PDF source recovery, when present, must embed the new Loro-native `.db8` package bytes only. It must not embed or recover the previous development `.db8` serializer payload, and it must not maintain dual old/new recovery formats. + +The only permitted verification fixtures in this plan are import/export/render regression fixtures for DOCX/PDF/export behavior. The broader architecture plan should not specify a general test suite at this stage. + +## 27. Schema Versioning + +The root Loro document should include explicit schema metadata: + +```text +MetaMap + document_id + loro_schema_version + schema_features + created_by_app_version + last_written_by_app_version + created_at + modified_at +``` + +This is not backwards compatibility for old `.db8`. It is forward compatibility for future Loro-native schema evolution. + +Open decision: whether migrations are stored as explicit revision/history records or only as package metadata. Current recommendation is to record schema migration events in package metadata and, if they mutate the Loro document, make those mutations ordinary Loro changes with a migration origin. + +## 28. Container References + +Container references must store both durable Flowstate IDs and raw Loro container IDs. + +The durable Flowstate ID is the semantic identity. The raw Loro container ID is stored for direct resolution and efficient runtime access. + +Example: + +```text +CellMap + flow_id: FlowId + +flows_by_id[flow_id] + text: LoroText +``` + +The schema should not depend on user-facing code hand-authoring raw `ContainerID` strings. Container resolution should be centralized in the CRDT runtime/schema module. + +## 29. Clear/Rebuild Paths + +The current code has a `clear_blocks` path because it sometimes rebuilds the Loro block list from the old `Document` model. That is a symptom of the current dual-model architecture. + +In the target architecture, full clear-and-repopulate paths should be rare. Normal editing should mutate Loro incrementally. + +When a legitimate full rebuild is needed, such as creating a new document from DOCX import or replacing a disposable projection cache, use Loro's exposed clear API for the relevant container instead of deleting one item at a time. + +Do not use full clear/repopulate as a normal local edit path, because it destroys useful incremental history and creates poor collaboration semantics. + +## 30. Invariants + +The architecture must preserve these invariants: + +1. A document can be fully reconstructed from the Loro snapshot/update history plus asset store. +2. Flowstate render/layout/search/export caches are disposable. +3. Paragraph split and inline text insertion commute correctly. +4. Undo/redo is based on grouped Loro changes and is per-peer/per-replica. +5. Time travel is based on Loro frontiers, not undo stack state. +6. Restoring a historical revision opens a dirty fork/branch tab rather than destructively replacing current history. +7. Binary assets are referenced by Loro metadata and stored outside the CRDT operation log. +8. Image captions and alt text are CRDT text flows. +9. Equation source is a CRDT text flow. +10. Rich inline formatting uses semantic Loro marks. +11. Paragraph style lives in Loro paragraph-boundary marks, not in competing paragraph metadata. +12. Paragraph/object/table metadata lives in Loro maps/lists. +13. Tables are structured CRDT objects, not binary blobs. +14. Rows and columns have durable Loro-native identity. +15. Remote updates are imported into Loro directly. +16. Local commands mutate Loro through the CRDT runtime. +17. There is no canonical Flowstate document separate from Loro. +18. There is no local-vs-remote document mutation split. +19. Files are Loro-backed packages, not final-state-only serialized documents. +20. Projection caches declare the Loro frontier they represent. +21. Search indexes declare the Loro frontier they represent. +22. DOCX/PDF/export operate from Loro snapshot projections. +23. Comments, annotations, bookmarks, and suggestion systems are out of scope for this architecture pass. +24. General test suite requirements are out of scope for this plan; only DOCX/PDF/export regression fixtures may be specified. + +## 31. Data Shape Summary + +Root: + +```text +RootMap + meta: MetaMap + flows_by_id: LoroMap + blocks_by_id: LoroMap + paragraphs_by_id: LoroMap + sections_by_id: LoroMap + assets_by_id: LoroMap + revisions: LoroList + users_by_id: LoroMap + replicas_by_id: LoroMap +``` + +Flow: + +```text +FlowMap + id + kind + text: LoroText + attrs +``` + +Paragraph: + +```text +ParagraphMap + id + flow_id + start_cursor + boundary_cursor + attrs +``` + +Object block: + +```text +BlockMap + id + kind + flow_id + anchor_cursor + attrs + nested_refs +``` + +Table: + +```text +TableBlock + row_order: LoroMovableList + rows_by_id: LoroMap + column_order: LoroMovableList + columns_by_id: LoroMap + cells_by_id: LoroMap + +CellMap + row_id + column_id + row_span + column_span + flow_id + attrs +``` + +Image: + +```text +ImageBlock + asset_id + content_hash + attrs + alt_text_flow_id + caption_flow_id +``` + +Equation: + +```text +EquationBlock + source_flow_id + attrs +``` + +Asset: + +```text +AssetMap + asset_id + content_hash + mime_type + byte_length + dimensions + metadata +``` + +Revision: + +```text +RevisionMap + id + timestamp + author_user_id + replica_id + frontier + title + summary +``` + +## 32. Expected Result + +The final architecture should make local editing, collaboration, persistence, undo/redo, revision history, branching, import/export, and search all expressions of the same underlying Loro document history. + +There should no longer be a special collaboration document model. + +There should no longer be a normal local document model. + +There should be one document model: + +```text +LoroDoc + asset store + derived Flowstate projections +``` + +Flowstate becomes the editor, renderer, importer, exporter, and search/projector for a Loro-native rich document, not the owner of a separate document format that Loro shadows. diff --git a/crates/flowstate-collab/Cargo.toml b/crates/flowstate-collab/Cargo.toml new file mode 100644 index 00000000..23a781eb --- /dev/null +++ b/crates/flowstate-collab/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "flowstate-collab" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +anyhow = { workspace = true } +async-channel = { workspace = true } +flowstate-document.workspace = true +gpui-flowtext = { workspace = true } +iroh = { workspace = true } +iroh-gossip = { workspace = true } +iroh-tickets = { workspace = true } +loro = { workspace = true } +n0-future = { workspace = true } +postcard = { workspace = true } +rand = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +twox-hash = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/flowstate-collab/src/crdt_runtime.rs b/crates/flowstate-collab/src/crdt_runtime.rs new file mode 100644 index 00000000..d145c1bb --- /dev/null +++ b/crates/flowstate-collab/src/crdt_runtime.rs @@ -0,0 +1,6914 @@ +use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering as AtomicOrdering}, + }, +}; + +use anyhow::{Context as _, Result}; +use flowstate_document::{ + AssetId, AssetRecord, BLOCKS_BY_ID, Block, BlockId, DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD, DocumentPackage, DocumentProjection, + FLOW_ATTRS_KEY, FLOW_ID_KEY, FLOW_KIND_KEY, FLOW_TEXT_KEY, FLOWS_BY_ID, ImportedLoroDocument, InputBlock, InputBlockAlignment, + InputEquationDisplay, InputImageSizing, InputParagraph, InputTableBlock, InputTableCell, InputTableCellBlock, InputTableColumnWidth, + InputTableRow, MAIN_BODY_BLOCK_ID, MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, + MARK_STRIKETHROUGH, OBJECT_REPLACEMENT, PARAGRAPHS_BY_ID, Paragraph, ParagraphId, ParagraphStyle, ProjectionPatch, ProjectionStructuralBlock, + ROOT, ROOT_BODY_FLOW_ID, ROOT_FIRST_PARAGRAPH_ID, RunSemanticStyle, RunStyles, SENTINEL_NEWLINE, SectionId, TableBlock, document_from_loro, + import_document_projection, loro_import::assets_from_document, loro_schema::body_text, new_loro_document, +}; +use gpui_flowtext::SemanticEditCommand as EditorSemanticCommand; +use loro::{ + Container, ContainerID, ExportMode, Frontiers, ImportStatus, LoroDoc, LoroMap, LoroMovableList, LoroText, LoroValue, Subscription, + UndoItemMeta, UndoManager, ValueOrContainer, VersionRange, VersionVector, + cursor::{Cursor, Side}, + event::{Diff, DiffEvent}, +}; +use rustc_hash::FxHashMap; +use uuid::Uuid; + +#[path = "crdt_runtime/projection_patch.rs"] +mod projection_patch; +#[path = "crdt_runtime/types.rs"] +mod types; +use crate::presence::{PresenceSelection, SelectionAffinity, SelectionDirection, SelectionEndpoint, VisualGravity}; +use gpui_flowtext::{DocumentOffset, EditorSelection, ExternalCaret, apply_projection_patches}; +use loro::{ContainerTrait as _, cursor::PosType}; +use projection_patch::{ + body_input_paragraph, projection_patches_between, remote_body_projection_patches, remote_nonstructural_projection_patches, +}; +use types::UndoSelectionState; +pub use types::{ + ProjectionFallbackStats, ProjectionInvalidation, ProjectionTextRange, RuntimeAssetMetadata, RuntimeEvent, RuntimePresenceCaretRequest, + RuntimePresenceCarets, RuntimeRevisionInfo, SemanticCommand, StaleProjectionError, UndoSelectionAffinity, UndoSelectionDirection, + UndoSelectionSnapshot, +}; + +#[derive(Debug)] +pub struct CrdtRuntime { + doc: LoroDoc, + projection: DocumentProjection, + projection_index: ProjectionRuntimeIndex, + undo: UndoManager, + defer_undo_checkpoints: bool, + undo_checkpoint_pending: bool, + // §15: optional durable author identity. When set, revisions record this user + // as their author; when `None`, authorship stays unset (behavior unchanged). + author_user_id: Option, + package: Option, + package_path: Option, + package_journal_prepared: bool, + last_persisted_frontier: Frontiers, + last_persisted_vv: VersionVector, + undo_selection: Arc>, + subscription_events: Arc>>, + // §23: monotonic runtime epoch. Bumped on every full projection rebuild/reset so + // the permanent subscription can discard buffered summaries stamped before the + // reset instead of relying on synchronous drain timing during import/checkout. + runtime_epoch: Arc, + local_subscription_updates: Arc>>>, + projection_fallback_counts: Mutex>, + _root_subscription: Subscription, + _local_update_subscription: Subscription, +} + +/// §24 style interval index entry: one run's byte span within a paragraph plus +/// its styles. `start`/`len` are byte offsets into the paragraph text. +#[derive(Clone, Copy, Debug)] +struct StyleInterval { + start: usize, + len: usize, + styles: RunStyles, +} + +/// §24 table row/column/cell index entry for one table block. +/// +/// The projection's [`TableBlock`] does not expose durable row/column/cell ids +/// (`TableRow`/`TableCell` carry no identifiers), so rows and columns are keyed +/// by positional index and `cells` records the `(row_ix, col_ix)` coordinate of +/// every materialized cell. This positional fallback is intentional and matches +/// the §24 note for projections without durable table ids. +#[derive(Clone, Debug, Default)] +struct TableIndexEntry { + row_ids: Vec, + column_ids: Vec, + cells: Vec<(usize, usize)>, +} + +/// §24 search unit span: a lightweight body-unicode range for one search unit. +/// `paragraph` is `Some(ix)` for a paragraph unit and `None` for an object +/// placeholder unit. Used to map changed body ranges onto affected search units. +#[derive(Clone, Copy, Debug)] +struct SearchUnitSpan { + paragraph: Option, + unicode_start: usize, + unicode_len: usize, +} + +#[derive(Debug, Default)] +struct ProjectionRuntimeIndex { + // Original three indexes (§ earlier work): behavior and population unchanged. + paragraph_body_unicode_starts: Vec, + paragraph_boundary_positions: Vec, + object_placeholder_positions: Vec, + // §24 additive projection indexes, all derived from the projection in + // `from_projection` and rebuilt whenever the original three are rebuilt. + /// Paragraph metadata index: paragraph id → paragraph index. + paragraph_metadata_by_id: FxHashMap, + /// Block anchor index: block id → block index in `projection.blocks`. + block_anchor_by_id: FxHashMap, + /// Table row/column/cell index: table block id → its positional cell layout. + table_cells_by_block: FxHashMap, + /// Style interval index: paragraph index → its run style intervals. + style_runs_by_paragraph: FxHashMap>, + /// Section anchor index: section id → its start paragraph id. + section_anchor_by_id: FxHashMap, + /// Asset reference index: asset id → blocks (image blocks) referencing it. + asset_refs_by_id: FxHashMap>, + /// Search unit index: per-paragraph and per-object body-unicode spans. + search_unit_spans: Vec, + /// Cursor resolution cache: encoded Loro cursor bytes → resolved document + /// offset. Memoizes `resolve_undo_cursor`. Positions shift on any edit, so the + /// cache is emptied on every rebuild (a fresh `from_projection`) and cleared in + /// `update_for_patches` for incremental updates. + cursor_resolution_cache: FxHashMap, DocumentOffset>, +} + +impl ProjectionRuntimeIndex { + fn from_projection(projection: &DocumentProjection) -> Self { + let mut index = Self::default(); + let mut body_unicode = 1usize; + let mut paragraph_ix = 0usize; + let mut has_body_content = false; + + for (block_ix, block) in projection.blocks.iter().enumerate() { + // §24 block anchor index: block id → block index. `block_ids` is parallel + // to `blocks`, so the first occurrence matches the prior `.position` scans. + if let Some(block_id) = projection.ids.block_ids.get(block_ix) { + index + .block_anchor_by_id + .entry(*block_id) + .or_insert(block_ix); + } + match block { + Block::Paragraph(paragraph) => { + if has_body_content { + index.paragraph_boundary_positions.push(body_unicode); + body_unicode = body_unicode.saturating_add(1); + } else { + index.paragraph_boundary_positions.push(0); + } + let paragraph_start = body_unicode; + index.paragraph_body_unicode_starts.push(paragraph_start); + let char_count = flowstate_document::paragraph_text(projection, paragraph_ix) + .chars() + .count(); + body_unicode = body_unicode.saturating_add(char_count); + // §24 paragraph metadata index: paragraph id → paragraph index. + if let Some(paragraph_id) = projection.ids.paragraph_ids.get(paragraph_ix) { + index + .paragraph_metadata_by_id + .entry(*paragraph_id) + .or_insert(paragraph_ix); + } + // §24 style interval index: per-paragraph run intervals. + index + .style_runs_by_paragraph + .insert(paragraph_ix, style_intervals_for_paragraph(paragraph)); + // §24 search unit index: one body-text span per paragraph. + index.search_unit_spans.push(SearchUnitSpan { + paragraph: Some(paragraph_ix), + unicode_start: paragraph_start, + unicode_len: char_count, + }); + paragraph_ix = paragraph_ix.saturating_add(1); + has_body_content = true; + }, + Block::Image(image) => { + index.object_placeholder_positions.push(body_unicode); + // §24 asset reference index: asset id → referencing image block ids. + if let Some(block_id) = projection.ids.block_ids.get(block_ix) { + index + .asset_refs_by_id + .entry(image.asset_id) + .or_default() + .push(*block_id); + } + index.search_unit_spans.push(SearchUnitSpan { + paragraph: None, + unicode_start: body_unicode, + unicode_len: 1, + }); + body_unicode = body_unicode.saturating_add(1); + has_body_content = true; + }, + Block::Equation(_) => { + index.object_placeholder_positions.push(body_unicode); + index.search_unit_spans.push(SearchUnitSpan { + paragraph: None, + unicode_start: body_unicode, + unicode_len: 1, + }); + body_unicode = body_unicode.saturating_add(1); + has_body_content = true; + }, + Block::Table(table) => { + index.object_placeholder_positions.push(body_unicode); + // §24 table row/column/cell index. + if let Some(block_id) = projection.ids.block_ids.get(block_ix) { + index + .table_cells_by_block + .insert(*block_id, table_index_entry(table)); + } + index.search_unit_spans.push(SearchUnitSpan { + paragraph: None, + unicode_start: body_unicode, + unicode_len: 1, + }); + body_unicode = body_unicode.saturating_add(1); + has_body_content = true; + }, + } + } + + // §24 section anchor index: section id → start paragraph id. + for section in projection.sections.iter() { + index + .section_anchor_by_id + .entry(section.id) + .or_insert(section.start_paragraph); + } + + index + } + + fn body_unicode_for_offset(&self, projection: &DocumentProjection, offset: DocumentOffset) -> Option { + let paragraph = projection.paragraphs.get(offset.paragraph)?; + let paragraph_text = flowstate_document::paragraph_text(projection, offset.paragraph); + let byte = offset + .byte + .min(flowstate_document::paragraph_text_len(paragraph)); + if !paragraph_text.is_char_boundary(byte) { + return None; + } + Some(*self.paragraph_body_unicode_starts.get(offset.paragraph)? + paragraph_text[..byte].chars().count()) + } + + fn offset_for_body_unicode(&self, projection: &DocumentProjection, unicode: usize) -> Option { + let paragraph_ix = self.paragraph_at_body_unicode(unicode, projection.paragraphs.len())?; + let paragraph_start = self + .paragraph_body_unicode_starts + .get(paragraph_ix) + .copied() + .unwrap_or_default(); + let local_unicode = unicode.saturating_sub(paragraph_start); + let paragraph_text = flowstate_document::paragraph_text(projection, paragraph_ix); + let byte = paragraph_text + .char_indices() + .nth(local_unicode) + .map_or(paragraph_text.len(), |(byte, _)| byte); + Some(DocumentOffset { + paragraph: paragraph_ix, + byte, + }) + } + + fn paragraphs_for_changed_ranges(&self, ranges: &[ProjectionTextRange], paragraph_count: usize) -> Vec { + let mut touched = std::collections::BTreeSet::new(); + for range in ranges + .iter() + .filter(|range| range.flow_id == ROOT_BODY_FLOW_ID) + { + let start = self.paragraph_at_body_unicode(range.unicode_start, paragraph_count); + let end = self.paragraph_at_body_unicode(range.unicode_start.saturating_add(range.unicode_len), paragraph_count); + if let Some(start) = start { + touched.insert(start); + } + if let Some(end) = end { + touched.insert(end); + } + if let (Some(start), Some(end)) = (start, end) { + touched.extend(start.min(end)..=start.max(end)); + } + } + touched.into_iter().collect() + } + + fn paragraph_at_body_unicode(&self, unicode: usize, paragraph_count: usize) -> Option { + if paragraph_count == 0 || self.paragraph_body_unicode_starts.is_empty() { + return None; + } + match self.paragraph_body_unicode_starts.binary_search(&unicode) { + Ok(ix) => Some(ix.min(paragraph_count - 1)), + Err(0) => Some(0), + Err(ix) => Some((ix - 1).min(paragraph_count - 1)), + } + } + + fn deleted_range_contains_structure(&self, start: usize, len: usize) -> bool { + if len == 0 { + return false; + } + let end = start.saturating_add(len); + self + .paragraph_boundary_positions + .iter() + .chain(&self.object_placeholder_positions) + .any(|position| (start..end).contains(position)) + } + + /// §24 paragraph metadata index lookup: paragraph id → paragraph index. + /// + /// O(1) through `paragraph_metadata_by_id`, falling back to the prior linear + /// `position` scan when the id is absent so the semantics (first match, or + /// `None`) are identical to the scans this replaces. + fn paragraph_index_for_id(&self, projection: &DocumentProjection, id: ParagraphId) -> Option { + if let Some(¶graph_ix) = self.paragraph_metadata_by_id.get(&id) { + return Some(paragraph_ix); + } + projection + .ids + .paragraph_ids + .iter() + .position(|candidate| *candidate == id) + } + + /// §24 block anchor index lookup: block id → block index in `projection.blocks`. + /// + /// O(1) through `block_anchor_by_id`, with the same linear fallback so callers + /// keep identical semantics to the `projection.ids.block_ids` scans. + fn block_index_for_id(&self, projection: &DocumentProjection, id: BlockId) -> Option { + if let Some(&block_ix) = self.block_anchor_by_id.get(&id) { + return Some(block_ix); + } + projection + .ids + .block_ids + .iter() + .position(|candidate| *candidate == id) + } + + /// §24 style interval index lookup: run styles covering `byte` in a paragraph. + fn run_styles_at(&self, paragraph_ix: usize, byte: usize) -> Option { + self + .style_runs_by_paragraph + .get(¶graph_ix)? + .iter() + .find(|interval| byte >= interval.start && byte < interval.start.saturating_add(interval.len)) + .map(|interval| interval.styles) + } + + /// §24 search unit index lookup: indices of search-unit spans overlapping any + /// body-text range in `ranges`. Mirrors `paragraphs_for_changed_ranges` but + /// resolves search units rather than paragraphs. + fn search_units_for_changed_ranges(&self, ranges: &[ProjectionTextRange]) -> Vec { + self + .search_unit_spans + .iter() + .enumerate() + .filter_map(|(unit_ix, span)| { + let unit_end = span.unicode_start.saturating_add(span.unicode_len.max(1)); + let overlaps = ranges + .iter() + .filter(|range| range.flow_id == ROOT_BODY_FLOW_ID) + .any(|range| { + let range_end = range.unicode_start.saturating_add(range.unicode_len.max(1)); + span.unicode_start < range_end && range.unicode_start < unit_end + }); + overlaps.then_some(unit_ix) + }) + .collect() + } + + fn update_for_patches(&mut self, projection: &DocumentProjection, patches: &[ProjectionPatch]) -> bool { + // §24: resolved cursor offsets shift whenever the projection's positions + // change, so invalidate the memoized cursor cache on every incremental + // update. (Full rebuilds construct a fresh index, which starts empty.) + self.cursor_resolution_cache.clear(); + let mut text_deltas = Vec::new(); + let mut rebuild = false; + for patch in patches { + match patch { + ProjectionPatch::ParagraphText { row, new, .. } => { + let Some(paragraph_ix) = paragraph_index_for_block_row(projection, *row) else { + rebuild = true; + break; + }; + let old_len = flowstate_document::paragraph_text(projection, paragraph_ix) + .chars() + .count(); + let new_len = new + .runs + .iter() + .map(|run| run.text.chars().count()) + .sum::(); + text_deltas.push((paragraph_ix, new_len as isize - old_len as isize)); + }, + ProjectionPatch::InsertBlocks { .. } | ProjectionPatch::DeleteBlocks { .. } | ProjectionPatch::MoveBlock { .. } => { + rebuild = true; + break; + }, + ProjectionPatch::ParagraphStyle { .. } + | ProjectionPatch::ParagraphRuns { .. } + | ProjectionPatch::ReplaceObjectBlock { .. } + | ProjectionPatch::AssetArrived { .. } => {}, + } + } + if rebuild { + return true; + } + for (paragraph_ix, delta) in text_deltas { + if delta == 0 { + continue; + } + for start in self + .paragraph_body_unicode_starts + .iter_mut() + .skip(paragraph_ix.saturating_add(1)) + { + *start = start.saturating_add_signed(delta); + } + for boundary in self + .paragraph_boundary_positions + .iter_mut() + .skip(paragraph_ix.saturating_add(1)) + { + *boundary = boundary.saturating_add_signed(delta); + } + let threshold = self + .paragraph_body_unicode_starts + .get(paragraph_ix) + .copied() + .unwrap_or_default(); + for placeholder in self + .object_placeholder_positions + .iter_mut() + .filter(|position| **position > threshold) + { + *placeholder = placeholder.saturating_add_signed(delta); + } + for span in &mut self.search_unit_spans { + if span.paragraph == Some(paragraph_ix) { + span.unicode_len = span.unicode_len.saturating_add_signed(delta); + } else if span.unicode_start > threshold { + span.unicode_start = span.unicode_start.saturating_add_signed(delta); + } + } + } + false + } +} + +fn paragraph_index_for_block_row(projection: &DocumentProjection, row: usize) -> Option { + matches!(projection.blocks.get(row), Some(Block::Paragraph(_))).then(|| { + projection + .blocks + .iter() + .take(row) + .filter(|block| matches!(block, Block::Paragraph(_))) + .count() + }) +} + +/// §24 style interval index builder: maps a paragraph's runs to byte-offset +/// `{start, len, styles}` intervals. Run lengths are byte lengths, matching the +/// projection's `TextRun::len`. +fn style_intervals_for_paragraph(paragraph: &Paragraph) -> Vec { + let mut intervals = Vec::with_capacity(paragraph.runs.len()); + let mut start = 0usize; + for run in ¶graph.runs { + intervals.push(StyleInterval { + start, + len: run.len, + styles: run.styles, + }); + start = start.saturating_add(run.len); + } + intervals +} + +/// §24 table row/column/cell index builder. The projection exposes no durable +/// row/column/cell ids, so rows and columns are keyed by positional index and +/// each materialized cell records its `(row_ix, col_ix)` coordinate. +fn table_index_entry(table: &TableBlock) -> TableIndexEntry { + let mut cells = Vec::new(); + for (row_ix, row) in table.rows.iter().enumerate() { + cells.extend((0..row.cells.len()).map(|col_ix| (row_ix, col_ix))); + } + TableIndexEntry { + row_ids: (0..table.rows.len()).collect(), + column_ids: (0..table.column_widths.len()).collect(), + cells, + } +} + +impl CrdtRuntime { + pub fn new_empty(title: &str) -> Result { + let doc = new_loro_document(title).context("initializing Loro document")?; + Self::from_doc(doc, None, None) + } + + pub fn open_package(path: impl AsRef) -> Result { + let path = path.as_ref(); + let package = DocumentPackage::read(path).with_context(|| format!("reading Flowstate package {}", path.display()))?; + let projection = package + .current_projection_document() + .context("reading frontier-matched package projection cache")?; + let doc = package + .load_loro_doc() + .context("loading Loro document from package")?; + let mut runtime = Self::from_doc_with_projection(doc, Some(package), Some(path.to_path_buf()), projection)?; + runtime.package_journal_prepared = true; + Ok(runtime) + } + + pub fn from_package(package: DocumentPackage, package_path: Option) -> Result { + let projection = package + .current_projection_document() + .context("reading frontier-matched package projection cache")?; + let doc = package + .load_loro_doc() + .context("loading Loro document from package")?; + Self::from_doc_with_projection(doc, Some(package), package_path, projection) + } + + pub fn from_document_projection(document: &DocumentProjection, title: &str) -> Result { + let imported = import_document_projection(document.clone(), title).context("importing projected document into canonical Loro runtime")?; + Self::from_imported_document(imported) + } + + pub fn from_imported_document(imported: ImportedLoroDocument) -> Result { + let ImportedLoroDocument { doc, projection } = imported; + Self::from_doc_with_projection_options(doc, None, None, Some(projection), false) + } + + pub fn from_doc(doc: LoroDoc, package: Option, package_path: Option) -> Result { + Self::from_doc_with_projection(doc, package, package_path, None) + } + + fn from_doc_with_projection( + doc: LoroDoc, + package: Option, + package_path: Option, + projection: Option, + ) -> Result { + Self::from_doc_with_projection_options(doc, package, package_path, projection, true) + } + + fn from_doc_with_projection_options( + doc: LoroDoc, + mut package: Option, + package_path: Option, + projection: Option, + repair_paragraph_style_marks: bool, + ) -> Result { + let frontier_before_startup_metadata = doc.state_frontiers().encode(); + let projection_content_repaired = if repair_paragraph_style_marks { + persist_body_paragraph_style_mark_repair(&doc, package.as_mut(), package_path.as_deref())? + } else { + // Trusted import builders apply every paragraph boundary mark while constructing + // the body. Avoid materializing a full rich-text delta only to verify it again. + flowstate_document::register_replica(&doc, None)?; + false + }; + let current_frontier = doc.state_frontiers().encode(); + let mut projection = match projection { + Some(projection) if projection.frontier == current_frontier => projection, + Some(mut projection) if !projection_content_repaired && projection.frontier == frontier_before_startup_metadata => { + projection.frontier.clone_from(¤t_frontier); + projection + }, + None => document_from_loro(&doc).context("building initial projection from canonical Loro state")?, + Some(_) => document_from_loro(&doc).context("rebuilding stale initial projection")?, + }; + if let Some(package) = &package { + attach_package_assets(&mut projection, package); + } + let last_persisted_frontier = doc.state_frontiers(); + let last_persisted_vv = doc.state_vv(); + let subscription_events = Arc::new(Mutex::new(Vec::new())); + let subscription_events_for_callback = Arc::clone(&subscription_events); + let runtime_epoch = Arc::new(AtomicU64::new(0)); + let runtime_epoch_for_callback = Arc::clone(&runtime_epoch); + // §23: the callback owns a reference clone of the document (a shared handle, not a + // deep fork) so it can stamp the live state frontier on every summary. This is + // deadlock-safe: Loro releases the doc state lock before dispatching observers + // (see `emit_events`: "we should not hold the lock when emitting events"), and + // `state_frontiers()` only takes that same lock briefly to clone the frontier. + let doc_for_callback = doc.clone(); + let root_subscription = doc.subscribe_root(Arc::new(move |event: DiffEvent<'_>| { + let mut summary = summarize_subscription_event(&event); + // §23: stamp the runtime epoch and the emit-time frontier. Summaries stamped + // before a full rebuild (different epoch) or ahead of the drain target (later + // frontier) are filtered in `merge_subscription_invalidation`. + summary.epoch = runtime_epoch_for_callback.load(AtomicOrdering::SeqCst); + summary.frontier = doc_for_callback.state_frontiers().encode(); + tracing::trace!( + origin = %summary.origin, + trigger = %summary.triggered_by, + epoch = summary.epoch, + frontier_len = summary.frontier.len(), + changes = summary.changes.len(), + "Flowstate Loro root event", + ); + if let Ok(mut events) = subscription_events_for_callback.lock() { + events.push(summary); + } + })); + let local_subscription_updates = Arc::new(Mutex::new(Vec::new())); + let local_updates_for_callback = Arc::clone(&local_subscription_updates); + let local_update_subscription = doc.subscribe_local_update(Box::new(move |bytes| { + tracing::trace!(bytes = bytes.len(), "Flowstate Loro local update"); + if let Ok(mut updates) = local_updates_for_callback.lock() { + updates.push(bytes.clone()); + } + true + })); + let mut undo = UndoManager::new(&doc); + undo.set_merge_interval(600); + undo.set_max_undo_steps(300); + undo.add_exclude_origin_prefix("remote"); + let undo_selection = Arc::new(Mutex::new(UndoSelectionState::default())); + install_undo_selection_callbacks(&mut undo, &undo_selection); + let projection_index = ProjectionRuntimeIndex::from_projection(&projection); + Ok(Self { + doc, + projection, + projection_index, + undo, + defer_undo_checkpoints: false, + undo_checkpoint_pending: false, + author_user_id: None, + package, + package_path, + package_journal_prepared: false, + last_persisted_frontier, + last_persisted_vv, + undo_selection, + subscription_events, + runtime_epoch, + local_subscription_updates, + projection_fallback_counts: Mutex::new(BTreeMap::new()), + _root_subscription: root_subscription, + _local_update_subscription: local_update_subscription, + }) + } + + pub(crate) fn doc(&self) -> &LoroDoc { + &self.doc + } + + /// §15: bind a stable durable author identity to this live replica. + /// + /// Registers (or refreshes) the user record in `users_by_id`, links the current + /// Loro replica to that user, and stores the id so later revisions record it as + /// their author. Until this is called, `author_user_id` stays `None` and + /// authorship is left unset (behavior unchanged). + pub fn set_author_identity(&mut self, user_id: u128, display_name: Option) -> Result<()> { + flowstate_document::register_user(&self.doc, user_id, display_name.as_deref()).context("registering durable author identity")?; + self.doc.commit(); + self.author_user_id = Some(user_id); + Ok(()) + } + + pub fn set_pending_undo_selection(&mut self, selection: Option) -> Result<()> { + let pending_selection = selection + .map(|selection| postcard::to_stdvec(&selection).context("encoding undo selection snapshot failed")) + .transpose()?; + if let Ok(mut state) = self.undo_selection.lock() { + state.pending_selection = pending_selection; + } + Ok(()) + } + + pub fn take_restored_undo_selection(&mut self) -> Option { + self + .undo_selection + .lock() + .ok() + .and_then(|mut state| state.restored_selection.take()) + } + + fn record_undo_checkpoint(&mut self) -> Result<()> { + if self.defer_undo_checkpoints { + self.undo_checkpoint_pending = true; + return Ok(()); + } + self + .undo + .record_new_checkpoint() + .context("recording Loro undo checkpoint")?; + Ok(()) + } + + fn undo_selection_for_editor(&self, selection: &EditorSelection) -> Option { + let direction = selection_direction(selection.anchor, selection.head); + // §16: source affinity from the editor selection's stored intent rather + // than re-deriving it from selection direction. + let anchor_affinity = SelectionAffinity::from(selection.anchor_affinity); + let head_affinity = SelectionAffinity::from(selection.head_affinity); + let body = body_text(&self.doc); + let anchor = clamp_projection_offset(&self.projection, selection.anchor); + let head = clamp_projection_offset(&self.projection, selection.head); + let anchor_pos = self + .projection_index + .body_unicode_for_offset(&self.projection, anchor)?; + let head_pos = self + .projection_index + .body_unicode_for_offset(&self.projection, head)?; + let anchor_cursor = cursor_for_boundary(&body, anchor_pos, anchor_affinity)?.encode(); + let head_cursor = cursor_for_boundary(&body, head_pos, head_affinity)?.encode(); + Some(UndoSelectionSnapshot { + anchor_cursor, + head_cursor, + anchor_affinity: undo_affinity(anchor_affinity), + head_affinity: undo_affinity(head_affinity), + direction: match direction { + SelectionDirection::Forward => UndoSelectionDirection::Forward, + SelectionDirection::Backward => UndoSelectionDirection::Backward, + SelectionDirection::None => UndoSelectionDirection::None, + }, + }) + } + + pub fn apply_editor_semantic_command( + &mut self, + projection: &DocumentProjection, + command: &EditorSemanticCommand, + ) -> Result> { + self.apply_editor_semantic_command_with_projection(projection, command, true) + } + + pub fn apply_editor_semantic_command_without_projection( + &mut self, + projection: &DocumentProjection, + command: &EditorSemanticCommand, + ) -> Result> { + self.apply_editor_semantic_command_with_projection(projection, command, false) + } + + pub fn try_apply_editor_semantic_command_without_projection(&mut self, command: &EditorSemanticCommand) -> Result>> { + let from_frontier = self.doc.state_frontiers(); + let from_vv = self.doc.state_vv(); + if apply_editor_semantic_command_body_fast_path(&self.doc, &self.projection, &self.projection_index, command)? { + self.record_undo_checkpoint()?; + let mut invalidation = ProjectionInvalidation::body_text( + from_frontier.encode(), + self.doc.state_frontiers().encode(), + 0, + body_text(&self.doc).len_unicode(), + ); + self.merge_subscription_invalidation(&mut invalidation); + let mut events = self.events_after_local_change(from_frontier, from_vv, invalidation.clone(), false)?; + if let Some(patches) = incremental_projection_patches_for_command(&self.projection, &self.projection_index, &self.doc, command) { + self.apply_projection_patch_set(&patches); + self.projection.frontier = self.doc.state_frontiers().encode(); + events.push(self.projection_patched_event(patches, invalidation)); + } else { + let before_projection = self.projection.clone(); + self.refresh_projection()?; + events.push(self.projection_change_event(&before_projection, invalidation)?); + } + return Ok(Some(events)); + } + Ok(None) + } + + fn apply_editor_semantic_command_with_projection( + &mut self, + projection: &DocumentProjection, + command: &EditorSemanticCommand, + emit_projection: bool, + ) -> Result> { + let from_frontier = self.doc.state_frontiers(); + let from_vv = self.doc.state_vv(); + if apply_editor_semantic_command(&self.doc, projection, command)? { + self.record_undo_checkpoint()?; + let mut invalidation = editor_command_invalidation(projection, command, from_frontier.encode(), self.doc.state_frontiers().encode()); + self.merge_subscription_invalidation(&mut invalidation); + let mut events = self.events_after_local_change(from_frontier, from_vv, invalidation.clone(), false)?; + if emit_projection { + if let Some(patches) = incremental_projection_patches_for_command(&self.projection, &self.projection_index, &self.doc, command) { + self.apply_projection_patch_set(&patches); + self.projection.frontier = self.doc.state_frontiers().encode(); + events.push(self.projection_patched_event(patches, invalidation)); + } else { + let before_projection = self.projection.clone(); + self.refresh_projection()?; + events.push(self.projection_change_event(&before_projection, invalidation)?); + } + } else { + self.refresh_projection()?; + } + Ok(events) + } else { + Ok(Vec::new()) + } + } + + pub fn projection_snapshot(&self) -> Result { + Ok(self.projection.clone()) + } + + pub fn asset_metadata(&self) -> Result> { + let root = self.doc.get_map(ROOT); + let Some(ValueOrContainer::Container(Container::Map(assets_by_id))) = root.get(flowstate_document::loro_schema::ASSETS_BY_ID) else { + return Ok(Vec::new()); + }; + let mut assets = Vec::new(); + for key in assets_by_id.keys() { + let Some(ValueOrContainer::Container(Container::Map(map))) = assets_by_id.get(&key) else { + continue; + }; + let Some(asset_id) = map_string_opt(&map, "asset_id").and_then(|value| value.parse::().ok()) else { + continue; + }; + let byte_length = map_i64_opt(&map, "byte_length").unwrap_or_default().max(0) as u64; + let Some(content_hash) = map_string_opt(&map, "content_hash").and_then(|hash| parse_blake3_hex(&hash)) else { + tracing::warn!(asset_id, "ignoring asset metadata with an invalid BLAKE3 digest"); + continue; + }; + if byte_length == 0 { + continue; + } + assets.push(RuntimeAssetMetadata { + asset_id, + content_hash, + mime_type: map_string_opt(&map, "mime_type").unwrap_or_else(|| "application/octet-stream".to_string()), + original_name: map_string_opt(&map, "original_name"), + byte_length, + }); + } + Ok(assets) + } + + pub fn revisions(&self) -> Vec { + self + .package + .as_ref() + .map(|package| { + package + .revisions + .iter() + .rev() + .map(|revision| RuntimeRevisionInfo { + revision_id: revision.revision_id, + title: revision.title.clone(), + summary: revision.summary.clone(), + created_at_unix_secs: revision.created_at_unix_secs, + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn presence_selection(&self, selection: &EditorSelection) -> Option { + let direction = selection_direction(selection.anchor, selection.head); + // §16: read the genuine, stored affinity/gravity off each endpoint instead + // of guessing a side from the selection's direction. + let anchor_affinity = SelectionAffinity::from(selection.anchor_affinity); + let head_affinity = SelectionAffinity::from(selection.head_affinity); + let anchor_gravity = VisualGravity::from(selection.anchor_gravity); + let head_gravity = VisualGravity::from(selection.head_gravity); + Some(PresenceSelection { + anchor: self.presence_endpoint(selection.anchor, anchor_affinity, anchor_gravity)?, + head: self.presence_endpoint(selection.head, head_affinity, head_gravity)?, + direction, + }) + } + + pub fn resolve_presence_carets(&self, requests: Vec) -> RuntimePresenceCarets { + let text = body_text(&self.doc); + let carets = requests + .into_iter() + .filter_map(|request| { + let cursor = Cursor::decode(&request.selection.head.cursor).ok()?; + if cursor.container != text.id() { + return None; + } + let resolved = self.doc.get_cursor_pos(&cursor).ok()?; + let unicode = resolved_cursor_boundary_unicode(&text, &resolved)?; + Some(ExternalCaret { + offset: self + .projection_index + .offset_for_body_unicode(&self.projection, unicode)?, + visual_gravity: gpui_gravity_from_presence(request.selection.head.visual_gravity), + color_rgb: request.color_rgb, + }) + }) + .collect(); + RuntimePresenceCarets { carets } + } + + fn presence_endpoint(&self, offset: DocumentOffset, affinity: SelectionAffinity, visual_gravity: VisualGravity) -> Option { + let text = body_text(&self.doc); + let offset = clamp_projection_offset(&self.projection, offset); + let pos = self + .projection_index + .body_unicode_for_offset(&self.projection, offset)?; + cursor_for_boundary(&text, pos, affinity).map(|cursor| SelectionEndpoint { + cursor: cursor.encode(), + affinity, + visual_gravity, + }) + } + + pub fn merge_asset_records(&mut self, records: Vec) -> Result> { + if records.is_empty() { + return Ok(Vec::new()); + } + let before = self.projection.clone(); + let from_frontier = self.doc.state_frontiers(); + let from_vv = self.doc.state_vv(); + let frontier_before = from_frontier.encode(); + let mut changed_asset_ids = Vec::new(); + for record in records { + let changed = self + .projection + .assets + .assets + .get(&record.id) + .is_none_or(|existing| asset_record_changed(existing, &record)); + if !changed { + continue; + } + changed_asset_ids.push(record.id.0.to_string()); + self.projection.assets.assets.insert(record.id, record); + } + if changed_asset_ids.is_empty() { + return Ok(Vec::new()); + } + flowstate_document::touch_document_metadata(&self.doc).context("updating canonical document metadata for asset change")?; + flowstate_document::loro_import::import_assets(&self.doc, &self.projection).context("recording asset metadata in canonical Loro state")?; + refresh_image_asset_metadata(&self.doc).context("refreshing image asset integrity metadata")?; + self.doc.commit(); + if let Some(package) = &mut self.package { + package.replace_assets_from_document(&self.projection)?; + if let Some(path) = &self.package_path { + package.append_assets_to_path(path)?; + } + } + let mut invalidation = ProjectionInvalidation { + frontier_before, + frontier_after: self.doc.state_frontiers().encode(), + changed_assets: changed_asset_ids, + ..ProjectionInvalidation::default() + }; + self.merge_subscription_invalidation(&mut invalidation); + let mut events = self.events_after_local_change(from_frontier, from_vv, invalidation.clone(), false)?; + events.push(self.projection_change_event(&before, invalidation)?); + Ok(events) + } + + pub fn apply_editor_commands( + &mut self, + base_frontier: &[u8], + commands: &[EditorSemanticCommand], + selection_after: Option<&EditorSelection>, + ) -> Result> { + if commands.is_empty() { + return Ok(Vec::new()); + } + // Editor commands are authored against the visible projection frontier, not + // necessarily the raw Loro doc frontier. Metadata-only commits such as + // author registration can advance the doc without changing visible content; + // treating those as stale causes optimistic typing to flash and roll back. + let current_projection_frontier = self.projection.frontier.clone(); + if !base_frontier.is_empty() && base_frontier != current_projection_frontier.as_slice() { + return Err( + StaleProjectionError { + expected_frontier_len: base_frontier.len(), + current_frontier_len: current_projection_frontier.len(), + } + .into(), + ); + } + if let Some(selection) = selection_after.and_then(|selection| self.undo_selection_for_editor(selection)) { + self.set_pending_undo_selection(Some(selection))?; + } + self.defer_undo_checkpoints = true; + self.undo_checkpoint_pending = false; + let result = (|| { + let mut events = Vec::new(); + flowstate_document::touch_document_metadata(&self.doc).context("updating canonical document metadata for editor command batch")?; + for command in commands { + let command_events = if let Some(events) = self.try_apply_editor_semantic_command_without_projection(command)? { + events + } else { + let projection = self.projection.clone(); + self.apply_editor_semantic_command_with_projection(&projection, command, true)? + }; + events.extend(command_events); + } + Ok(events) + })(); + self.defer_undo_checkpoints = false; + if result.is_ok() && self.undo_checkpoint_pending { + self + .undo + .record_new_checkpoint() + .context("recording grouped Loro undo checkpoint")?; + } + self.undo_checkpoint_pending = false; + result + } + + pub fn command(&mut self, command: SemanticCommand) -> Result> { + let restore_undo_selection = matches!(&command, SemanticCommand::Undo | SemanticCommand::Redo); + let before_projection = self.projection.clone(); + let before_body = body_text(&self.doc).to_string(); + let from_frontier = self.doc.state_frontiers(); + let from_vv = self.doc.state_vv(); + let mutates_document = match &command { + SemanticCommand::InsertText { text, .. } => !text.is_empty(), + SemanticCommand::DeleteRange { unicode_len, .. } => *unicode_len > 0, + SemanticCommand::OpenRevision { .. } | SemanticCommand::ForkRevision { .. } | SemanticCommand::Undo | SemanticCommand::Redo => false, + _ => true, + }; + if mutates_document { + flowstate_document::touch_document_metadata(&self.doc).context("updating canonical document metadata for semantic command")?; + } + let projection_invalidation; + match command { + SemanticCommand::InsertText { unicode_index, text, styles } => { + if text.is_empty() { + return Ok(Vec::new()); + } + let body = body_text(&self.doc); + let newline_boundaries = inserted_newline_boundaries(unicode_index, &text); + body + .insert(unicode_index, &text) + .context("inserting text into Loro body flow")?; + let inserted_len = text.chars().count(); + if inserted_len > 0 { + mark_run_styles(&body, unicode_index..unicode_index + inserted_len, styles).context("marking inserted run styles")?; + } + repair_paragraph_metadata_after_text_flow_edit(&self.doc, &body, &newline_boundaries, "semantic_insert_text")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_text(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_index, inserted_len); + }, + SemanticCommand::DeleteRange { unicode_index, unicode_len } => { + if unicode_len > 0 { + let body = body_text(&self.doc); + body + .delete(unicode_index, unicode_len) + .context("deleting text from Loro body flow")?; + repair_paragraph_metadata_after_text_flow_edit(&self.doc, &body, &[], "semantic_delete_range")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_text(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_index, unicode_len); + } else { + return Ok(Vec::new()); + } + }, + SemanticCommand::SplitParagraph { + unicode_index, + inherited_style, + } => { + let body = body_text(&self.doc); + body + .insert(unicode_index, "\n") + .context("splitting Loro body paragraph")?; + body + .mark( + unicode_index..unicode_index + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(inherited_style), + ) + .context("marking split paragraph boundary")?; + repair_paragraph_metadata_after_text_flow_edit(&self.doc, &body, &[unicode_index], "semantic_split_paragraph")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_text(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_index, 1); + }, + SemanticCommand::SetParagraphStyle { + boundary_unicode_index, + style, + } => { + let body = body_text(&self.doc); + body + .mark( + boundary_unicode_index..boundary_unicode_index + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(style), + ) + .context("marking paragraph style in Loro body flow")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_style(from_frontier.encode(), self.doc.state_frontiers().encode(), boundary_unicode_index, 1); + }, + SemanticCommand::SetRunStyles { unicode_range, styles } => { + if unicode_range.is_empty() { + return Ok(Vec::new()); + } + let unicode_start = unicode_range.start; + let unicode_len = unicode_range.end.saturating_sub(unicode_range.start); + mark_run_styles(&body_text(&self.doc), unicode_range, styles).context("marking run styles in Loro body flow")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_style(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_start, unicode_len); + }, + SemanticCommand::InsertImage { + unicode_index, + asset_id, + alt_text, + caption, + sizing, + alignment, + } => { + insert_image_block(&self.doc, unicode_index, asset_id, &alt_text, caption.as_deref(), sizing, alignment) + .context("inserting image block into Loro document")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_object(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_index, "image"); + }, + SemanticCommand::InsertEquation { + unicode_index, + source, + display, + } => { + insert_equation_block(&self.doc, unicode_index, &source, display).context("inserting equation block into Loro document")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_object(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_index, "equation"); + }, + SemanticCommand::InsertTable { + unicode_index, + rows, + columns, + column_widths, + header_row, + } => { + insert_table_block(&self.doc, unicode_index, rows, columns, &column_widths, header_row) + .context("inserting table block into Loro document")?; + self.doc.commit(); + self.record_undo_checkpoint()?; + projection_invalidation = + ProjectionInvalidation::body_object(from_frontier.encode(), self.doc.state_frontiers().encode(), unicode_index, "table"); + }, + SemanticCommand::OpenRevision { revision_id } => { + let document = self.revision_projection(revision_id)?; + return Ok(vec![RuntimeEvent::RevisionOpened { + revision_id, + document: Box::new(document), + }]); + }, + SemanticCommand::ForkRevision { revision_id } => { + let (document, package) = self.fork_revision(revision_id)?; + return Ok(vec![RuntimeEvent::RevisionForked { + revision_id, + document: Box::new(document), + package: Box::new(package), + }]); + }, + SemanticCommand::Undo => { + if !self.undo.undo().context("applying Loro undo")? { + return Ok(Vec::new()); + } + projection_invalidation = ProjectionInvalidation { + frontier_before: from_frontier.encode(), + frontier_after: self.doc.state_frontiers().encode(), + changed_flows: vec![ROOT_BODY_FLOW_ID.to_string()], + ..ProjectionInvalidation::default() + }; + }, + SemanticCommand::Redo => { + if !self.undo.redo().context("applying Loro redo")? { + return Ok(Vec::new()); + } + projection_invalidation = ProjectionInvalidation { + frontier_before: from_frontier.encode(), + frontier_after: self.doc.state_frontiers().encode(), + changed_flows: vec![ROOT_BODY_FLOW_ID.to_string()], + ..ProjectionInvalidation::default() + }; + }, + } + let mut projection_invalidation = projection_invalidation; + self.merge_subscription_invalidation(&mut projection_invalidation); + let mut events = self.events_after_local_change(from_frontier, from_vv, projection_invalidation.clone(), false)?; + let after_body = body_text(&self.doc).to_string(); + if let Some(patches) = remote_body_projection_patches(&before_projection, &before_body, &after_body, &self.doc, &projection_invalidation) { + self.apply_projection_patch_set(&patches); + self.projection.frontier = self.doc.state_frontiers().encode(); + events.push(self.projection_patched_event(patches, projection_invalidation)); + } else { + self.refresh_projection()?; + let reason = if restore_undo_selection { + "undo_redo_structural_projection_fallback" + } else { + "semantic_command_structural_projection_fallback" + }; + events.push(self.projection_change_event( + &before_projection, + ProjectionInvalidation::full_rebuild(projection_invalidation.frontier_before, projection_invalidation.frontier_after, reason), + )?); + } + if restore_undo_selection && let Some(snapshot) = self.take_restored_undo_selection() { + if let Some(selection) = self.resolve_undo_selection(&snapshot) { + events.push(RuntimeEvent::SelectionRestored { selection }); + } else if let Ok(mut state) = self.undo_selection.lock() { + state.restored_selection = Some(snapshot); + } + } + Ok(events) + } + + fn resolve_undo_selection(&mut self, snapshot: &UndoSelectionSnapshot) -> Option { + // §16: restore the stored affinity onto the rebuilt selection. Gravity is + // not persisted in the undo snapshot, so it resolves to neutral. + // §24: `&mut self` so the cursor resolutions can memoize through the index's + // cursor cache without interior mutability (this stays on the actor's + // single-threaded `&mut self` command flow). + Some(EditorSelection { + anchor: self.resolve_undo_cursor(&snapshot.anchor_cursor)?, + head: self.resolve_undo_cursor(&snapshot.head_cursor)?, + anchor_affinity: gpui_affinity_from_undo(snapshot.anchor_affinity), + head_affinity: gpui_affinity_from_undo(snapshot.head_affinity), + anchor_gravity: gpui_flowtext::VisualGravity::Neutral, + head_gravity: gpui_flowtext::VisualGravity::Neutral, + }) + } + + fn resolve_undo_cursor(&mut self, encoded: &[u8]) -> Option { + // §24 cursor resolution cache: memoize the (expensive) `get_cursor_pos` + // resolution keyed by the encoded cursor bytes. The cache is cleared on every + // projection rebuild/incremental update, so a hit always reflects the current + // projection. + if let Some(offset) = self.projection_index.cursor_resolution_cache.get(encoded) { + return Some(*offset); + } + let cursor = Cursor::decode(encoded).ok()?; + let body = body_text(&self.doc); + if cursor.container != body.id() { + return None; + } + let resolved = self.doc.get_cursor_pos(&cursor).ok()?; + let unicode = resolved_cursor_boundary_unicode(&body, &resolved)?; + let offset = self + .projection_index + .offset_for_body_unicode(&self.projection, unicode)?; + self + .projection_index + .cursor_resolution_cache + .insert(encoded.to_vec(), offset); + Some(offset) + } + + pub fn revision_projection(&self, revision_id: u128) -> Result { + let revision_doc = self + .package + .as_ref() + .context("cannot open revision without a package-backed runtime")? + .load_revision_loro_doc(revision_id) + .context("loading revision Loro snapshot")?; + let mut document = document_from_loro(&revision_doc).context("projecting revision document")?; + if let Some(package) = &self.package { + attach_package_assets(&mut document, package); + } + Ok(document) + } + + pub fn fork_revision(&self, revision_id: u128) -> Result<(DocumentProjection, DocumentPackage)> { + let package = self + .package + .as_ref() + .context("cannot fork revision without a package-backed runtime")?; + let revision_doc = package + .load_revision_loro_doc(revision_id) + .context("loading revision Loro snapshot for fork")?; + let forked_doc = revision_doc.fork(); + flowstate_document::fork_document_lineage(&forked_doc).context("assigning forked document lineage")?; + let forked_package = DocumentPackage::from_loro_snapshot_with_assets(&forked_doc, "Forked revision", package.assets.clone()) + .context("creating forked revision package")?; + let mut document = document_from_loro(&forked_doc).context("projecting forked revision")?; + attach_package_assets(&mut document, &forked_package); + Ok((document, forked_package)) + } + + pub fn import_remote_update(&mut self, bytes: &[u8]) -> Result> { + let from_frontier = self.doc.state_frontiers(); + let status = self + .doc + .import_with(bytes, "remote") + .context("importing remote Loro update")?; + let after_remote_vv = self.doc.state_vv(); + let repair_update = if status.pending.is_none() && repair_missing_paragraph_style_marks(&self.doc)? { + self.local_update_bytes(&after_remote_vv)? + } else { + Vec::new() + }; + let frontier_after = self.doc.state_frontiers(); + let version_vector = self.doc.state_vv(); + // §22: when the import is missing dependencies, surface the pending version + // range so the UI session can trigger immediate update pull/anti-entropy + // rather than waiting for the periodic digest. The range is both logged here + // and carried on `RemoteUpdateApplied { pending }` below. + if let Some(missing) = Self::missing_dependency_request(&status) { + tracing::debug!(?missing, "remote Loro import is missing dependencies; requesting anti-entropy pull"); + } + let mut events = vec![RuntimeEvent::RemoteUpdateApplied { + pending: status.pending.clone(), + frontier: frontier_after.encode(), + version_vector: version_vector.encode(), + }]; + if !repair_update.is_empty() { + events.push(RuntimeEvent::LocalUpdate { + bytes: repair_update, + frontier: frontier_after.encode(), + version_vector: version_vector.encode(), + }); + } + let frontier_before = from_frontier.encode(); + let frontier_after = frontier_after.encode(); + if status.pending.is_none() { + let mut invalidation = ProjectionInvalidation { + frontier_before, + frontier_after, + changed_flows: vec![ROOT_BODY_FLOW_ID.to_string()], + ..ProjectionInvalidation::default() + }; + self.merge_subscription_invalidation(&mut invalidation); + let touched_paragraphs = self + .projection_index + .paragraphs_for_changed_ranges(&invalidation.changed_text_ranges, self.projection.paragraphs.len()); + if let Some(patches) = remote_nonstructural_projection_patches(&self.projection, &self.doc, &invalidation, &touched_paragraphs) { + self.apply_projection_patch_set(&patches); + self.projection.frontier = self.doc.state_frontiers().encode(); + events.push(self.projection_patched_event(patches, invalidation)); + } else { + let before_projection = self.projection.clone(); + self.refresh_projection()?; + events.push(self.projection_change_event(&before_projection, invalidation)?); + } + } else { + let mut invalidation = ProjectionInvalidation::full_rebuild(frontier_before, frontier_after, "remote_update_pending_projection_fallback"); + self.merge_subscription_invalidation(&mut invalidation); + self.refresh_projection()?; + events.push(self.projection_event(invalidation)?); + } + if status.pending.is_none() { + if let Some(package) = &mut self.package { + package.sync_revisions_from_loro(&self.doc)?; + } + self.persist_update_from_last_frontier()?; + } + Ok(events) + } + + fn projection_event(&self, invalidation: ProjectionInvalidation) -> Result { + self.record_projection_fallback(&invalidation); + Ok(RuntimeEvent::ProjectionUpdated { + document: Box::new(self.projection_snapshot()?), + invalidation, + frontier: self.doc.state_frontiers().encode(), + version_vector: self.doc.state_vv().encode(), + }) + } + + pub fn export_updates_for(&self, remote_vv: &VersionVector) -> Result> { + self + .doc + .export(ExportMode::updates(remote_vv)) + .context("exporting Loro updates for anti-entropy") + } + + pub fn missing_dependency_request(status: &ImportStatus) -> Option<&VersionRange> { + status.pending.as_ref() + } + + pub fn save_package(&mut self) -> io::Result<()> { + let Some(package) = &self.package else { + return Ok(()); + }; + let Some(path) = &self.package_path else { + return Ok(()); + }; + package.write(path)?; + self.package_journal_prepared = true; + Ok(()) + } + + fn projection_change_event(&self, before: &DocumentProjection, invalidation: ProjectionInvalidation) -> Result { + if let Some(patches) = projection_patches_between(before, &self.projection) { + self.record_projection_fallback(&invalidation); + return Ok(RuntimeEvent::ProjectionPatched { + patches, + invalidation, + frontier: self.doc.state_frontiers().encode(), + version_vector: self.doc.state_vv().encode(), + }); + } + self.projection_event(ProjectionInvalidation::full_rebuild( + invalidation.frontier_before, + invalidation.frontier_after, + "projection_diff_ambiguous", + )) + } + + fn projection_patched_event(&self, patches: Vec, invalidation: ProjectionInvalidation) -> RuntimeEvent { + RuntimeEvent::ProjectionPatched { + patches, + invalidation, + frontier: self.doc.state_frontiers().encode(), + version_vector: self.doc.state_vv().encode(), + } + } + + fn record_projection_fallback(&self, invalidation: &ProjectionInvalidation) { + if !invalidation.rebuild_required { + return; + } + let reason = invalidation + .fallback_reason + .unwrap_or("unspecified_projection_fallback"); + if let Ok(mut counts) = self.projection_fallback_counts.lock() { + *counts.entry(reason.to_string()).or_default() += 1; + } + tracing::warn!(reason, "Flowstate projection used a full rebuild fallback"); + } + + pub fn projection_fallback_stats(&self) -> ProjectionFallbackStats { + let by_reason = self + .projection_fallback_counts + .lock() + .map(|counts| counts.clone()) + .unwrap_or_default(); + ProjectionFallbackStats { + total: by_reason.values().copied().sum(), + by_reason, + } + } + + fn refresh_projection(&mut self) -> Result<()> { + let mut projection = document_from_loro(&self.doc).context("refreshing projection from canonical Loro state")?; + if let Some(package) = &self.package { + attach_package_assets(&mut projection, package); + } + projection.theme = self.projection.theme.clone(); + self.projection = projection; + self.projection_index = ProjectionRuntimeIndex::from_projection(&self.projection); + // §23: a full rebuild discards the meaning of any incremental summary buffered + // before this point. Every full-rebuild path (local structural fallback, remote + // non-structural fallback, and the pending/again-changed remote import that + // forces a rebuild) routes through here, so bumping once here covers them all. + self.bump_runtime_epoch(); + Ok(()) + } + + /// §23: advance the runtime epoch after a full projection reset/rebuild. + /// + /// `merge_subscription_invalidation` reads the live epoch and discards buffered + /// summaries stamped at an earlier epoch, so the permanent subscription stays + /// correct without depending on synchronous drain timing around import/checkout. + fn bump_runtime_epoch(&self) { + let previous = self.runtime_epoch.fetch_add(1, AtomicOrdering::SeqCst); + tracing::trace!( + previous_epoch = previous, + new_epoch = previous.wrapping_add(1), + "Flowstate runtime epoch bumped after full projection rebuild" + ); + } + + fn apply_projection_patch_set(&mut self, patches: &[ProjectionPatch]) { + let rebuild_index = self + .projection_index + .update_for_patches(&self.projection, patches); + apply_projection_patches(&mut self.projection, patches); + if rebuild_index { + self.projection_index = ProjectionRuntimeIndex::from_projection(&self.projection); + } + } + + pub fn save_package_to(&mut self, path: impl AsRef) -> io::Result<()> { + self.package_path = Some(path.as_ref().to_path_buf()); + self.package_journal_prepared = false; + self.save_package() + } + + pub fn checkpoint_package(&mut self, title: &str, path: Option) -> io::Result> { + let revision_id = Uuid::new_v4().as_u128(); + let revision_frontiers = self.doc.state_frontiers(); + let revision_frontier = revision_frontiers.encode(); + let from_frontier = self.doc.state_frontiers(); + let from_vv = self.doc.state_vv(); + flowstate_document::touch_document_metadata(&self.doc).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + flowstate_document::record_revision(&self.doc, revision_id, revision_frontier, title, "Explicit save", self.author_user_id) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let mut revision_invalidation = ProjectionInvalidation::default(); + self.merge_subscription_invalidation(&mut revision_invalidation); + let update = self + .local_update_bytes(&from_vv) + .map_err(|error| io::Error::other(error.to_string()))?; + let mut events = Vec::new(); + if !update.is_empty() { + let event_update = update.clone(); + self + .persist_update_segment(from_frontier, from_vv, update) + .map_err(|error| io::Error::other(error.to_string()))?; + events.push(RuntimeEvent::LocalUpdate { + bytes: event_update, + frontier: self.doc.state_frontiers().encode(), + version_vector: self.doc.state_vv().encode(), + }); + } + if self.package.is_none() { + let package_creation_vv = self.doc.state_vv(); + self.package = Some(DocumentPackage::from_loro_snapshot_with_assets( + &self.doc, + title, + assets_from_document(&self.projection), + )?); + let package_creation_update = self + .local_update_bytes(&package_creation_vv) + .map_err(|error| io::Error::other(error.to_string()))?; + if !package_creation_update.is_empty() { + events.push(RuntimeEvent::LocalUpdate { + bytes: package_creation_update, + frontier: self.doc.state_frontiers().encode(), + version_vector: self.doc.state_vv().encode(), + }); + } + self.last_persisted_frontier = self.doc.state_frontiers(); + self.last_persisted_vv = self.doc.state_vv(); + } + let Some(package) = &mut self.package else { + return Ok(events); + }; + package.replace_assets_from_document(&self.projection)?; + package.rebuild_projection_cache_from_loro(&self.doc)?; + package.rebuild_search_units_from_loro(&self.doc)?; + package.compact_to_snapshot(&self.doc)?; + package.create_named_revision_at_with_id( + &self.doc, + revision_id, + &revision_frontiers, + title, + "Explicit save", + self.author_user_id, + Some(self.doc.peer_id() as u128), + )?; + if let Some(path) = path { + self.package_path = Some(path); + self.package_journal_prepared = false; + } + self.save_package()?; + Ok(events) + } + + pub fn package_bytes(&mut self, title: &str) -> io::Result> { + if self.package.is_none() { + self.package = Some(DocumentPackage::from_loro_snapshot_with_assets( + &self.doc, + title, + assets_from_document(&self.projection), + )?); + } + let Some(package) = &mut self.package else { + return Err(io::Error::other("runtime package was not initialized")); + }; + package.replace_assets_from_document(&self.projection)?; + package.rebuild_projection_cache_from_loro(&self.doc)?; + package.rebuild_search_units_from_loro(&self.doc)?; + package.to_bytes() + } + + fn events_after_local_change( + &mut self, + from_frontier: Frontiers, + from_vv: VersionVector, + invalidation: ProjectionInvalidation, + emit_projection: bool, + ) -> Result> { + let update = self.local_update_bytes(&from_vv)?; + let mut events = Vec::new(); + if !update.is_empty() { + self.persist_update_segment(from_frontier, from_vv, update.clone())?; + events.push(RuntimeEvent::LocalUpdate { + bytes: update, + frontier: self.doc.state_frontiers().encode(), + version_vector: self.doc.state_vv().encode(), + }); + } + if emit_projection { + events.push(self.projection_event(invalidation)?); + } + Ok(events) + } + + fn local_update_bytes(&self, from_vv: &VersionVector) -> Result> { + let mut subscribed = self + .local_subscription_updates + .lock() + .map(|mut updates| std::mem::take(&mut *updates)) + .unwrap_or_default(); + if subscribed.len() == 1 { + return Ok(subscribed.pop().unwrap_or_default()); + } + self + .doc + .export(ExportMode::updates(from_vv)) + .context("exporting local Loro update fallback") + } + + /// §23: drain the permanent subscription buffer and fold the in-epoch, in-frontier + /// summaries into `invalidation`, filtering/processing by runtime epoch, emit-time + /// frontier, origin, and trigger. + /// + /// * Epoch — summaries stamped before the most recent full rebuild are discarded. + /// * Frontier — summaries stamped strictly ahead of `frontier_after` belong to a + /// later batch and are returned to the buffer for the next drain. + /// * Origin — a remote-origin summary sets `has_remote_origin` (telemetry/bias) + /// without forcing a rebuild, so the incremental remote fast paths still apply. + /// * Trigger — a checkout-triggered event forces a conservative full rebuild; + /// import/local triggers are left to the existing structural detection. + fn merge_subscription_invalidation(&self, invalidation: &mut ProjectionInvalidation) { + let summaries = self + .subscription_events + .lock() + .map(|mut events| std::mem::take(&mut *events)) + .unwrap_or_default(); + let body_target = body_text(&self.doc).id().to_string(); + let current_epoch = self.runtime_epoch.load(AtomicOrdering::SeqCst); + // §23 FRONTIER: decode the drain target once so each summary can be compared + // causally. Empty/undecodable targets (e.g. revision bookkeeping drains) disable + // the frontier filter and fall back to draining everything in-epoch. + let target_frontier = if invalidation.frontier_after.is_empty() { + None + } else { + Frontiers::decode(&invalidation.frontier_after).ok() + }; + let mut deferred: Vec = Vec::new(); + let mut has_remote_origin = false; + for summary in summaries { + // §23 EPOCH: drop summaries emitted before the latest full projection + // rebuild/reset; their incremental meaning no longer maps onto the projection. + if summary.epoch != current_epoch { + tracing::trace!( + summary_epoch = summary.epoch, + current_epoch, + origin = %summary.origin, + trigger = %summary.triggered_by, + "Flowstate discarding stale pre-reset Loro subscription summary", + ); + continue; + } + // §23 FRONTIER: a summary stamped strictly ahead of the drain target is from a + // later batch. Re-buffer it instead of misattributing it to this invalidation. + if let Some(target) = target_frontier.as_ref() + && !summary.frontier.is_empty() + && let Ok(summary_frontier) = Frontiers::decode(&summary.frontier) + && matches!(self.doc.cmp_frontiers(&summary_frontier, target), Ok(Some(std::cmp::Ordering::Greater))) + { + tracing::trace!( + origin = %summary.origin, + trigger = %summary.triggered_by, + "Flowstate deferring Loro subscription summary stamped ahead of the drain frontier", + ); + deferred.push(summary); + continue; + } + // §23 ORIGIN: record remote-origin processing. The UndoManager keeps its + // `add_exclude_origin_prefix("remote")` exclusion unchanged. + has_remote_origin |= summary.origin.starts_with("remote"); + // §23 TRIGGER: checkout events reflect time-travel/detached state that cannot be + // expressed as an incremental projection patch, so force a conservative rebuild. + // Ordinary local and import triggers preserve the incremental fast paths. + if summary.triggered_by.eq_ignore_ascii_case("checkout") { + invalidation.rebuild_required = true; + invalidation.fallback_reason = Some("checkout_trigger_projection_rebuild"); + tracing::debug!(origin = %summary.origin, "Flowstate forcing projection rebuild for checkout-triggered Loro event"); + } + for change in summary.changes { + match change { + SubscriptionChange::Text { + target, + unicode_start, + unicode_len, + deleted_len, + inserted_structure, + } if target == body_target => { + if inserted_structure + || self + .projection_index + .deleted_range_contains_structure(unicode_start, deleted_len) + { + invalidation.rebuild_required = true; + invalidation.fallback_reason = Some("structural_body_text_change"); + } + invalidation + .changed_flows + .push(ROOT_BODY_FLOW_ID.to_string()); + invalidation.changed_text_ranges.push(ProjectionTextRange { + flow_id: ROOT_BODY_FLOW_ID.to_string(), + unicode_start, + unicode_len, + }); + }, + SubscriptionChange::Text { target, .. } => invalidation.changed_flows.push(target), + SubscriptionChange::Map { target, keys } => classify_map_invalidation(invalidation, &target, &keys), + SubscriptionChange::List { target } => invalidation.changed_blocks.push(target), + SubscriptionChange::Unknown { target } => { + invalidation.rebuild_required = true; + invalidation.fallback_reason = Some("unknown_loro_subscription_diff"); + invalidation.changed_blocks.push(target); + }, + } + } + } + // §23 ORIGIN: surface remote-origin processing for downstream consumers/telemetry. + invalidation.has_remote_origin |= has_remote_origin; + // §24: fold the auxiliary projection indexes into the accumulated invalidation + // before the sort/dedup below so any enrichment is normalized with the rest. + self.consume_projection_indexes(invalidation); + invalidation.changed_flows.sort(); + invalidation.changed_flows.dedup(); + invalidation.changed_blocks.sort(); + invalidation.changed_blocks.dedup(); + invalidation.changed_tables.sort(); + invalidation.changed_tables.dedup(); + invalidation.changed_assets.sort(); + invalidation.changed_assets.dedup(); + invalidation.changed_sections.sort(); + invalidation.changed_sections.dedup(); + // §23 FRONTIER: return later-batch summaries to the front of the buffer (ahead of + // anything emitted after this drain) so ordering is preserved on the next drain. + if !deferred.is_empty() + && let Ok(mut events) = self.subscription_events.lock() + { + let newly_buffered = std::mem::replace(&mut *events, deferred); + events.extend(newly_buffered); + } + } + + /// §24: consume the auxiliary projection indexes while merging an invalidation. + /// + /// Two functional, additive, conservative enrichments and several behavior- + /// neutral diagnostic reads: + /// * Asset reference index — a changed asset id (the `merge_asset_records` + /// path records ids in their `u128` form) is mapped to the image blocks that + /// embed it, whose ids are appended to `changed_blocks`. + /// * Table row/column/cell index — a table-map diff names the Loro container, + /// not the projected block id, so when any table changed every indexed table + /// block id is surfaced into `changed_blocks`. + /// + /// Downstream code only ever tests `changed_blocks`/`changed_tables` for + /// *emptiness* (never their contents), so this can only widen coverage and + /// never drops a needed invalidation. The section/style/search reads do not + /// mutate the invalidation; they map it onto the remaining indexes so those + /// materialized structures have live consumers for the incremental work §24 + /// builds toward. + fn consume_projection_indexes(&self, invalidation: &mut ProjectionInvalidation) { + let index = &self.projection_index; + + // Asset reference index → changed_blocks. + if !invalidation.changed_assets.is_empty() && !index.asset_refs_by_id.is_empty() { + let mut referenced = Vec::new(); + for asset in &invalidation.changed_assets { + if let Ok(asset_id) = asset.parse::() + && let Some(blocks) = index.asset_refs_by_id.get(&AssetId(asset_id)) + { + referenced.extend(blocks.iter().map(|block| block.0.to_string())); + } + } + invalidation.changed_blocks.extend(referenced); + } + + // Table row/column/cell index → changed_blocks (conservative). + if !invalidation.changed_tables.is_empty() && !index.table_cells_by_block.is_empty() { + let mut total_cells = 0usize; + let mut dense_cells = 0usize; + let mut table_blocks = Vec::new(); + for (block_id, entry) in &index.table_cells_by_block { + table_blocks.push(block_id.0.to_string()); + total_cells = total_cells.saturating_add(entry.cells.len()); + dense_cells = dense_cells.saturating_add(entry.row_ids.len().saturating_mul(entry.column_ids.len())); + } + tracing::trace!( + tables = index.table_cells_by_block.len(), + total_cells = total_cells, + dense_cells = dense_cells, + "Flowstate §24 table change surfaced indexed table blocks", + ); + invalidation.changed_blocks.extend(table_blocks); + } + + // Search-unit and style-interval indexes (diagnostic, behavior-neutral). Gated + // on the trace level so the O(spans) mapping never runs on the hot text-edit + // path in production; the field reads stay compiled so the indexes are live. + if !invalidation.changed_text_ranges.is_empty() && tracing::enabled!(tracing::Level::TRACE) { + let touched_units = index.search_units_for_changed_ranges(&invalidation.changed_text_ranges); + let mut paragraph_units = 0usize; + let mut object_units = 0usize; + let mut styled_units = 0usize; + for unit_ix in &touched_units { + match index + .search_unit_spans + .get(*unit_ix) + .and_then(|span| span.paragraph) + { + Some(paragraph_ix) => { + paragraph_units += 1; + if index + .run_styles_at(paragraph_ix, 0) + .is_some_and(|styles| styles != RunStyles::default()) + { + styled_units += 1; + } + }, + None => object_units += 1, + } + } + tracing::trace!( + touched_units = touched_units.len(), + paragraph_units = paragraph_units, + object_units = object_units, + styled_units = styled_units, + "Flowstate §24 changed body ranges mapped onto search-unit / style-interval indexes", + ); + } + + // Section anchor index (diagnostic, behavior-neutral). + if !invalidation.changed_sections.is_empty() { + let anchored = index + .section_anchor_by_id + .values() + .filter(|start| index.paragraph_metadata_by_id.contains_key(*start)) + .count(); + tracing::trace!( + changed_sections = invalidation.changed_sections.len(), + anchored_sections = anchored, + sections = index.section_anchor_by_id.len(), + "Flowstate §24 section change mapped onto section-anchor index", + ); + } + } + + fn persist_update_from_last_frontier(&mut self) -> Result<()> { + let from_frontier = self.last_persisted_frontier.clone(); + let from_vv = self.last_persisted_vv.clone(); + let update = self + .doc + .export(ExportMode::updates(&from_vv)) + .context("exporting accepted remote Loro update for persistence")?; + if update.is_empty() { + return Ok(()); + } + self.persist_update_segment(from_frontier, from_vv, update) + } + + fn persist_update_segment(&mut self, from_frontier: Frontiers, from_vv: VersionVector, update: Vec) -> Result<()> { + if let Some(package) = &mut self.package { + package.append_update_segment(&from_frontier, &from_vv, &self.doc.state_frontiers(), &self.doc.state_vv(), update)?; + let compacted = package.compact_update_segments_if_needed(&self.doc, DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD)?; + if let Some(path) = &self.package_path { + if compacted.is_some() { + package.write(path)?; + self.package_journal_prepared = true; + } else if self.package_journal_prepared { + package.append_latest_update_to_prepared_path(path)?; + } else { + package.append_latest_update_to_path(path)?; + self.package_journal_prepared = true; + } + } + } + self.last_persisted_frontier = self.doc.state_frontiers(); + self.last_persisted_vv = self.doc.state_vv(); + Ok(()) + } +} + +fn summarize_subscription_event(event: &DiffEvent<'_>) -> SubscriptionEventSummary { + let mut changes = Vec::new(); + for container in &event.events { + let target = container.target.to_string(); + match &container.diff { + Diff::Text(delta) => { + let mut cursor = 0usize; + for item in delta { + match item { + loro::TextDelta::Retain { retain, attributes } => { + if attributes.is_some() { + changes.push(SubscriptionChange::Text { + target: target.clone(), + unicode_start: cursor, + unicode_len: *retain, + deleted_len: 0, + inserted_structure: false, + }); + } + cursor = cursor.saturating_add(*retain); + }, + loro::TextDelta::Insert { insert, .. } => { + let len = insert.chars().count(); + changes.push(SubscriptionChange::Text { + target: target.clone(), + unicode_start: cursor, + unicode_len: len, + deleted_len: 0, + inserted_structure: insert + .chars() + .any(|ch| ch == '\n' || ch == OBJECT_REPLACEMENT), + }); + cursor = cursor.saturating_add(len); + }, + loro::TextDelta::Delete { delete } => { + changes.push(SubscriptionChange::Text { + target: target.clone(), + unicode_start: cursor, + unicode_len: *delete, + deleted_len: *delete, + inserted_structure: false, + }); + }, + } + } + }, + Diff::Map(delta) => changes.push(SubscriptionChange::Map { + target, + keys: delta.updated.keys().map(|key| key.to_string()).collect(), + }), + Diff::List(_) => changes.push(SubscriptionChange::List { target }), + Diff::Tree(_) | Diff::Unknown => changes.push(SubscriptionChange::Unknown { target }), + Diff::Counter(_) => changes.push(SubscriptionChange::Unknown { target }), + } + } + SubscriptionEventSummary { + origin: event.origin.to_string(), + triggered_by: format!("{:?}", event.triggered_by), + // §23: `summarize_subscription_event` stays pure — it cannot read the runtime + // epoch or the live doc frontier. These are stamped by the root callback after + // summarization. The unstamped defaults (epoch 0 / empty frontier) are treated + // as "not stamped" by `merge_subscription_invalidation`. + epoch: 0, + frontier: Vec::new(), + changes, + } +} + +fn classify_map_invalidation(invalidation: &mut ProjectionInvalidation, target: &str, keys: &[String]) { + if keys.iter().any(|key| { + matches!( + key.as_str(), + "asset_id" | "content_hash" | "mime_type" | "byte_length" | "dimensions" | "original_name" + ) + }) { + invalidation.changed_assets.push(target.to_string()); + } + if keys.iter().any(|key| { + matches!( + key.as_str(), + "row_order" | "rows_by_id" | "column_order" | "columns_by_id" | "cells_by_id" | "row_span" | "column_span" + ) + }) { + invalidation.changed_tables.push(target.to_string()); + } + if keys + .iter() + .any(|key| matches!(key.as_str(), "kind" | "flow_id" | "anchor_cursor" | "attrs" | "nested_refs")) + { + invalidation.changed_blocks.push(target.to_string()); + } + if keys + .iter() + .any(|key| key == "section_id" || key == "sections_by_id") + { + invalidation.changed_sections.push(target.to_string()); + } +} + +pub fn apply_editor_semantic_command(doc: &LoroDoc, projection: &DocumentProjection, command: &EditorSemanticCommand) -> Result { + match command { + EditorSemanticCommand::InsertText { at, text, styles } => { + let unicode_index = projection_offset_to_body_unicode_index(projection, *at); + let body = body_text(doc); + let newline_boundaries = inserted_newline_boundaries(unicode_index, text); + body + .insert(unicode_index, text) + .context("inserting projection-scoped text command into Loro body flow")?; + let inserted_len = text.chars().count(); + if inserted_len > 0 { + mark_run_styles(&body, unicode_index..unicode_index + inserted_len, *styles).context("marking inserted run styles")?; + } + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &newline_boundaries, "editor_insert_text")?; + doc.commit(); + Ok(true) + }, + EditorSemanticCommand::DeleteRange { range } => { + let start = projection_offset_to_body_unicode_index(projection, range.start); + let end = projection_offset_to_body_unicode_index(projection, range.end); + if end > start { + let body = body_text(doc); + body + .delete(start, end - start) + .context("deleting projection-scoped text range from Loro body flow")?; + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &[], "editor_delete_range")?; + doc.commit(); + return Ok(true); + } + Ok(false) + }, + EditorSemanticCommand::SplitParagraph { at, inherited_style } => { + let unicode_index = projection_offset_to_body_unicode_index(projection, *at); + let body = body_text(doc); + body + .insert(unicode_index, "\n") + .context("splitting paragraph in Loro body flow")?; + body + .mark( + unicode_index..unicode_index + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(*inherited_style), + ) + .context("marking split paragraph style")?; + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &[unicode_index], "editor_split_paragraph")?; + doc.commit(); + Ok(true) + }, + EditorSemanticCommand::SetParagraphStyle { paragraph, style } => { + if let Some(paragraph_ix) = projection + .ids + .paragraph_ids + .iter() + .position(|id| id == paragraph) + { + let boundary = paragraph_boundary_unicode_index(projection, paragraph_ix); + body_text(doc) + .mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(*style)) + .context("marking paragraph style from editor semantic command")?; + doc.commit(); + return Ok(true); + } + Ok(false) + }, + EditorSemanticCommand::SetRunStyles { paragraph, range, styles } => { + if let Some(paragraph_ix) = projection + .ids + .paragraph_ids + .iter() + .position(|id| id == paragraph) + { + let start = projection_offset_to_body_unicode_index( + projection, + flowstate_document::DocumentOffset { + paragraph: paragraph_ix, + byte: range.start, + }, + ); + let end = projection_offset_to_body_unicode_index( + projection, + flowstate_document::DocumentOffset { + paragraph: paragraph_ix, + byte: range.end, + }, + ); + if end > start { + mark_run_styles(&body_text(doc), start..end, *styles).context("marking run styles from editor semantic command")?; + doc.commit(); + return Ok(true); + } + } + Ok(false) + }, + EditorSemanticCommand::JoinParagraphs { first, second } => { + join_projection_paragraphs(doc, projection, *first, *second).context("joining paragraphs from editor semantic command") + }, + EditorSemanticCommand::ReplaceParagraphSpan { start, before, after } => { + replace_body_paragraph_span(doc, projection, *start, before, after).context("replacing paragraph span from editor semantic command") + }, + EditorSemanticCommand::InsertBlock { block, block_ix, after } => insert_projection_object_block(doc, *block, *block_ix, after) + .with_context(|| format!("inserting object block from editor semantic command at projection block {block_ix} ({block:?})")), + EditorSemanticCommand::DeleteBlock { block } => { + delete_projection_object_block(doc, *block).context("deleting object block from editor semantic command") + }, + EditorSemanticCommand::MoveBlock { block, new_block_ix } => { + move_projection_object_block(doc, *block, *new_block_ix).context("moving object block from editor semantic command") + }, + EditorSemanticCommand::ReplaceBlock { block, block_ix, after } => replace_projection_object_block(doc, projection, *block, *block_ix, after) + .with_context(|| format!("replacing object block from editor semantic command at projection block {block_ix} ({block:?})")), + EditorSemanticCommand::InsertTableRow { table, row_ix, row } => insert_projection_table_row(doc, *table, *row_ix, row) + .with_context(|| format!("inserting table row from editor semantic command at table {table:?}, row {row_ix}")), + EditorSemanticCommand::DeleteTableRow { table, row_ix } => delete_projection_table_row(doc, *table, *row_ix) + .with_context(|| format!("deleting table row from editor semantic command at table {table:?}, row {row_ix}")), + EditorSemanticCommand::MoveTableRow { + table, + from_row_ix, + to_row_ix, + } => move_projection_table_axis(doc, *table, "row_order", *from_row_ix, *to_row_ix) + .with_context(|| format!("moving table row from {from_row_ix} to {to_row_ix} at table {table:?}")), + EditorSemanticCommand::InsertTableColumn { + table, + column_ix, + width, + cells, + } => insert_projection_table_column(doc, *table, *column_ix, width, cells) + .with_context(|| format!("inserting table column from editor semantic command at table {table:?}, column {column_ix}")), + EditorSemanticCommand::DeleteTableColumn { table, column_ix } => delete_projection_table_column(doc, *table, *column_ix) + .with_context(|| format!("deleting table column from editor semantic command at table {table:?}, column {column_ix}")), + EditorSemanticCommand::MoveTableColumn { + table, + from_column_ix, + to_column_ix, + } => move_projection_table_axis(doc, *table, "column_order", *from_column_ix, *to_column_ix) + .with_context(|| format!("moving table column from {from_column_ix} to {to_column_ix} at table {table:?}")), + EditorSemanticCommand::ReplaceTableCell { + table, + row_ix, + cell_ix, + cell, + } => replace_projection_table_cell(doc, *table, *row_ix, *cell_ix, cell) + .with_context(|| format!("replacing table cell from editor semantic command at table {table:?}, row {row_ix}, cell {cell_ix}")), + EditorSemanticCommand::SetTableCellSpan { + table, + row_ix, + cell_ix, + row_span, + column_span, + } => set_projection_table_cell_span(doc, *table, *row_ix, *cell_ix, *row_span, *column_span) + .with_context(|| format!("setting table cell span at table {table:?}, row {row_ix}, cell {cell_ix}")), + EditorSemanticCommand::ReplaceEquationSourceRange { equation, range, text } => { + replace_projection_equation_source_range(doc, *equation, range, text) + .with_context(|| format!("replacing equation source range from editor semantic command at equation {equation:?}, range {range:?}")) + }, + EditorSemanticCommand::ReplaceImageAltText { image, text } => replace_projection_image_alt_text(doc, *image, text) + .with_context(|| format!("replacing image alt text from editor semantic command at image {image:?}")), + EditorSemanticCommand::ReplaceImageCaption { image, caption } => replace_projection_image_caption(doc, *image, caption.as_ref()) + .with_context(|| format!("replacing image caption from editor semantic command at image {image:?}")), + EditorSemanticCommand::SetImageLayout { image, sizing, alignment } => set_projection_image_layout(doc, *image, sizing, *alignment) + .with_context(|| format!("setting image layout from editor semantic command at image {image:?}")), + EditorSemanticCommand::SetTableColumnWidth { table, column_ix, width } => set_projection_table_column_width(doc, *table, *column_ix, width) + .with_context(|| format!("setting table column width from editor semantic command at table {table:?}, column {column_ix}")), + } +} + +#[derive(Clone, Debug)] +struct SubscriptionEventSummary { + origin: String, + triggered_by: String, + // §23: runtime epoch read when the event was emitted. `summarize_subscription_event` + // leaves this at 0 (it stays a pure function of the diff); the permanent root + // callback stamps the live epoch before buffering. + epoch: u64, + // §23: doc state frontier captured at emit time (`doc.state_frontiers().encode()`). + // Left empty by `summarize_subscription_event` and stamped by the root callback, + // which holds a reference clone of the document. + frontier: Vec, + changes: Vec, +} + +#[derive(Clone, Debug)] +enum SubscriptionChange { + Text { + target: String, + unicode_start: usize, + unicode_len: usize, + deleted_len: usize, + inserted_structure: bool, + }, + Map { + target: String, + keys: Vec, + }, + List { + target: String, + }, + Unknown { + target: String, + }, +} + +fn apply_editor_semantic_command_body_fast_path( + doc: &LoroDoc, + projection: &DocumentProjection, + projection_index: &ProjectionRuntimeIndex, + command: &EditorSemanticCommand, +) -> Result { + match command { + EditorSemanticCommand::InsertText { at, text, styles } => { + let body = body_text(doc); + let Some(unicode_index) = projection_index.body_unicode_for_offset(projection, *at) else { + return Ok(false); + }; + let newline_boundaries = inserted_newline_boundaries(unicode_index, text); + body + .insert(unicode_index, text) + .context("inserting text into Loro body flow without projection snapshot")?; + let inserted_len = text.chars().count(); + if inserted_len > 0 { + mark_run_styles(&body, unicode_index..unicode_index + inserted_len, *styles).context("marking inserted run styles")?; + } + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &newline_boundaries, "editor_insert_text_fast_path")?; + doc.commit(); + Ok(true) + }, + EditorSemanticCommand::DeleteRange { range } => { + let body = body_text(doc); + let Some(start) = projection_index.body_unicode_for_offset(projection, range.start) else { + return Ok(false); + }; + let Some(end) = projection_index.body_unicode_for_offset(projection, range.end) else { + return Ok(false); + }; + if end > start { + body + .delete(start, end - start) + .context("deleting text from Loro body flow without projection snapshot")?; + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &[], "editor_delete_range_fast_path")?; + doc.commit(); + return Ok(true); + } + Ok(false) + }, + EditorSemanticCommand::SplitParagraph { at, inherited_style } => { + let body = body_text(doc); + let Some(unicode_index) = projection_index.body_unicode_for_offset(projection, *at) else { + return Ok(false); + }; + body + .insert(unicode_index, "\n") + .context("splitting paragraph in Loro body flow without projection snapshot")?; + body + .mark( + unicode_index..unicode_index + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(*inherited_style), + ) + .context("marking split paragraph style")?; + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &[unicode_index], "editor_split_paragraph_fast_path")?; + doc.commit(); + Ok(true) + }, + EditorSemanticCommand::SetParagraphStyle { .. } + | EditorSemanticCommand::SetRunStyles { .. } + | EditorSemanticCommand::JoinParagraphs { .. } + | EditorSemanticCommand::ReplaceParagraphSpan { .. } + | EditorSemanticCommand::InsertBlock { .. } + | EditorSemanticCommand::DeleteBlock { .. } + | EditorSemanticCommand::MoveBlock { .. } + | EditorSemanticCommand::ReplaceBlock { .. } + | EditorSemanticCommand::InsertTableRow { .. } + | EditorSemanticCommand::DeleteTableRow { .. } + | EditorSemanticCommand::MoveTableRow { .. } + | EditorSemanticCommand::InsertTableColumn { .. } + | EditorSemanticCommand::DeleteTableColumn { .. } + | EditorSemanticCommand::MoveTableColumn { .. } + | EditorSemanticCommand::ReplaceTableCell { .. } + | EditorSemanticCommand::SetTableCellSpan { .. } + | EditorSemanticCommand::ReplaceEquationSourceRange { .. } + | EditorSemanticCommand::ReplaceImageAltText { .. } + | EditorSemanticCommand::ReplaceImageCaption { .. } + | EditorSemanticCommand::SetImageLayout { .. } + | EditorSemanticCommand::SetTableColumnWidth { .. } => Ok(false), + } +} + +fn incremental_projection_patches_for_command( + projection: &DocumentProjection, + index: &ProjectionRuntimeIndex, + doc: &LoroDoc, + command: &EditorSemanticCommand, +) -> Option> { + match command { + EditorSemanticCommand::InsertText { at, text, .. } if !text.contains('\n') && !text.contains(OBJECT_REPLACEMENT) => { + let row = flowstate_document::block_ix_for_paragraph(projection, at.paragraph)?; + let old_len = flowstate_document::paragraph_text_len(projection.paragraphs.get(at.paragraph)?); + let new = body_input_paragraph(doc, at.paragraph)?; + Some(vec![flowstate_document::ProjectionPatch::ParagraphText { + row, + new, + delta_utf8: projection_text_delta(at.byte.min(old_len), 0, text.len(), old_len.saturating_sub(at.byte.min(old_len))), + }]) + }, + EditorSemanticCommand::DeleteRange { range } if range.start.paragraph == range.end.paragraph => { + let paragraph_ix = range.start.paragraph; + let row = flowstate_document::block_ix_for_paragraph(projection, paragraph_ix)?; + let old_len = flowstate_document::paragraph_text_len(projection.paragraphs.get(paragraph_ix)?); + let start = range.start.byte.min(old_len); + let end = range.end.byte.min(old_len).max(start); + let new = body_input_paragraph(doc, paragraph_ix)?; + Some(vec![flowstate_document::ProjectionPatch::ParagraphText { + row, + new, + delta_utf8: projection_text_delta(start, end - start, 0, old_len.saturating_sub(end)), + }]) + }, + EditorSemanticCommand::SetParagraphStyle { paragraph, style } => { + // §24: O(1) paragraph metadata index lookup replaces the linear + // `paragraph_ids` scan (with an identical linear fallback). + let paragraph_ix = index.paragraph_index_for_id(projection, *paragraph)?; + let row = flowstate_document::block_ix_for_paragraph(projection, paragraph_ix)?; + Some(vec![flowstate_document::ProjectionPatch::ParagraphStyle { row, style: *style }]) + }, + EditorSemanticCommand::SetRunStyles { paragraph, .. } => { + // §24: O(1) paragraph metadata index lookup replaces the linear + // `paragraph_ids` scan (with an identical linear fallback). + let paragraph_ix = index.paragraph_index_for_id(projection, *paragraph)?; + let row = flowstate_document::block_ix_for_paragraph(projection, paragraph_ix)?; + let new = body_input_paragraph(doc, paragraph_ix)?; + Some(vec![flowstate_document::ProjectionPatch::ParagraphRuns { + row, + runs: flowstate_document::document_from_input_blocks(projection.theme.clone(), vec![InputBlock::Paragraph(new)]) + .paragraphs + .first()? + .runs + .clone(), + }]) + }, + _ => structured_projection_patches_for_command(projection, index, command), + } +} + +fn structured_projection_patches_for_command( + projection: &DocumentProjection, + index: &ProjectionRuntimeIndex, + command: &EditorSemanticCommand, +) -> Option> { + match command { + EditorSemanticCommand::InsertBlock { block, block_ix, after } => Some(vec![ProjectionPatch::InsertBlocks { + row: (*block_ix).min(projection.blocks.len()), + blocks: vec![ProjectionStructuralBlock { + block_id: *block, + paragraph_id: None, + block: after.clone(), + }], + }]), + // §24: O(1) block anchor index lookups replace the linear `block_ids` scans + // (each with an identical linear fallback for ids absent from the index). + EditorSemanticCommand::DeleteBlock { block } => Some(vec![ProjectionPatch::DeleteBlocks { + row: index.block_index_for_id(projection, *block)?, + count: 1, + }]), + EditorSemanticCommand::MoveBlock { block, new_block_ix } => Some(vec![ProjectionPatch::MoveBlock { + from: index.block_index_for_id(projection, *block)?, + to: (*new_block_ix).min(projection.blocks.len().saturating_sub(1)), + }]), + EditorSemanticCommand::ReplaceBlock { block, block_ix, after } => object_replacement_patch( + projection, + block + .and_then(|id| index.block_index_for_id(projection, id)) + .unwrap_or(*block_ix), + after.clone(), + ), + EditorSemanticCommand::InsertTableRow { table, row_ix, row } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + table_input + .rows + .insert((*row_ix).min(table_input.rows.len()), row.clone()); + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::DeleteTableRow { table, row_ix } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + if *row_ix >= table_input.rows.len() { + return None; + } + table_input.rows.remove(*row_ix); + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::MoveTableRow { + table, + from_row_ix, + to_row_ix, + } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + if *from_row_ix >= table_input.rows.len() || *to_row_ix >= table_input.rows.len() { + return None; + } + let row = table_input.rows.remove(*from_row_ix); + table_input.rows.insert(*to_row_ix, row); + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::InsertTableColumn { + table, + column_ix, + width, + cells, + } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + let column_ix = (*column_ix).min(table_input.column_widths.len()); + table_input.column_widths.insert(column_ix, width.clone()); + for (row_ix, row) in table_input.rows.iter_mut().enumerate() { + row.cells.insert( + column_ix.min(row.cells.len()), + cells + .get(row_ix) + .cloned() + .unwrap_or_else(empty_input_table_cell), + ); + } + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::DeleteTableColumn { table, column_ix } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + if *column_ix >= table_input.column_widths.len() { + return None; + } + table_input.column_widths.remove(*column_ix); + for row in &mut table_input.rows { + if *column_ix < row.cells.len() { + row.cells.remove(*column_ix); + } + } + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::MoveTableColumn { + table, + from_column_ix, + to_column_ix, + } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + if *from_column_ix >= table_input.column_widths.len() || *to_column_ix >= table_input.column_widths.len() { + return None; + } + let width = table_input.column_widths.remove(*from_column_ix); + table_input.column_widths.insert(*to_column_ix, width); + for row in &mut table_input.rows { + if *from_column_ix < row.cells.len() && *to_column_ix < row.cells.len() { + let cell = row.cells.remove(*from_column_ix); + row.cells.insert(*to_column_ix, cell); + } + } + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::ReplaceTableCell { + table, + row_ix, + cell_ix, + cell, + } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + let target = table_input.rows.get_mut(*row_ix)?.cells.get_mut(*cell_ix)?; + *target = cell.clone(); + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::SetTableCellSpan { + table, + row_ix, + cell_ix, + row_span, + column_span, + } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + let cell = table_input.rows.get_mut(*row_ix)?.cells.get_mut(*cell_ix)?; + cell.row_span = (*row_span).max(1); + cell.col_span = (*column_span).max(1); + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::SetTableColumnWidth { table, column_ix, width } => { + let (block_ix, mut table_input) = projected_table_input(projection, index, *table)?; + *table_input.column_widths.get_mut(*column_ix)? = width.clone(); + object_replacement_patch(projection, block_ix, InputBlock::Table(table_input)) + }, + EditorSemanticCommand::ReplaceEquationSourceRange { equation, range, text } => { + // §24: O(1) block anchor index lookup (linear fallback preserved). + let block_ix = index.block_index_for_id(projection, *equation)?; + let InputBlock::Equation(mut equation_input) = flowstate_document::input_block_from_block(projection.blocks.get(block_ix)?) else { + return None; + }; + if range.start > range.end + || range.end > equation_input.source.len() + || !equation_input.source.is_char_boundary(range.start) + || !equation_input.source.is_char_boundary(range.end) + { + return None; + } + equation_input.source.replace_range(range.clone(), text); + object_replacement_patch(projection, block_ix, InputBlock::Equation(equation_input)) + }, + EditorSemanticCommand::ReplaceImageAltText { image, text } => { + // §24: O(1) block anchor index lookup (linear fallback preserved). + let block_ix = index.block_index_for_id(projection, *image)?; + let InputBlock::Image(mut image_input) = flowstate_document::input_block_from_block(projection.blocks.get(block_ix)?) else { + return None; + }; + image_input.alt_text = text.clone(); + object_replacement_patch(projection, block_ix, InputBlock::Image(image_input)) + }, + EditorSemanticCommand::ReplaceImageCaption { image, caption } => { + // §24: O(1) block anchor index lookup (linear fallback preserved). + let block_ix = index.block_index_for_id(projection, *image)?; + let InputBlock::Image(mut image_input) = projection + .blocks + .get(block_ix) + .map(flowstate_document::input_block_from_block)? + else { + return None; + }; + image_input.caption = caption.clone(); + object_replacement_patch(projection, block_ix, InputBlock::Image(image_input)) + }, + EditorSemanticCommand::SetImageLayout { image, sizing, alignment } => { + // §24: O(1) block anchor index lookup (linear fallback preserved). + let block_ix = index.block_index_for_id(projection, *image)?; + let InputBlock::Image(mut image_input) = flowstate_document::input_block_from_block(projection.blocks.get(block_ix)?) else { + return None; + }; + image_input.sizing = sizing.clone(); + image_input.alignment = *alignment; + object_replacement_patch(projection, block_ix, InputBlock::Image(image_input)) + }, + EditorSemanticCommand::InsertText { .. } + | EditorSemanticCommand::DeleteRange { .. } + | EditorSemanticCommand::SplitParagraph { .. } + | EditorSemanticCommand::JoinParagraphs { .. } + | EditorSemanticCommand::SetParagraphStyle { .. } + | EditorSemanticCommand::SetRunStyles { .. } + | EditorSemanticCommand::ReplaceParagraphSpan { .. } => None, + } +} + +fn projected_table_input( + projection: &DocumentProjection, + index: &ProjectionRuntimeIndex, + table: flowstate_document::BlockId, +) -> Option<(usize, InputTableBlock)> { + // §24: O(1) block anchor index lookup replaces the linear `block_ids` scan that + // every table structural command funnels through (identical linear fallback). + let block_ix = index.block_index_for_id(projection, table)?; + let InputBlock::Table(table) = flowstate_document::input_block_from_block(projection.blocks.get(block_ix)?) else { + return None; + }; + Some((block_ix, table)) +} + +fn object_replacement_patch(projection: &DocumentProjection, block_ix: usize, block: InputBlock) -> Option> { + Some(vec![ProjectionPatch::ReplaceObjectBlock { + row: block_ix, + block: ProjectionStructuralBlock { + block_id: *projection.ids.block_ids.get(block_ix)?, + paragraph_id: None, + block, + }, + }]) +} + +fn projection_text_delta( + prefix_retain: usize, + delete_len: usize, + insert_len: usize, + trailing_retain: usize, +) -> Vec { + let mut delta = Vec::new(); + if prefix_retain > 0 { + delta.push(flowstate_document::ProjectionTextDelta::Retain(prefix_retain)); + } + if delete_len > 0 { + delta.push(flowstate_document::ProjectionTextDelta::Delete(delete_len)); + } + if insert_len > 0 { + delta.push(flowstate_document::ProjectionTextDelta::Insert(insert_len)); + } + if trailing_retain > 0 { + delta.push(flowstate_document::ProjectionTextDelta::Retain(trailing_retain)); + } + delta +} + +fn editor_command_invalidation( + projection: &DocumentProjection, + command: &EditorSemanticCommand, + frontier_before: Vec, + frontier_after: Vec, +) -> ProjectionInvalidation { + match command { + EditorSemanticCommand::InsertText { at, text, .. } => ProjectionInvalidation::body_text( + frontier_before, + frontier_after, + projection_offset_to_body_unicode_index(projection, *at), + text.chars().count(), + ), + EditorSemanticCommand::DeleteRange { range } => { + let start = projection_offset_to_body_unicode_index(projection, range.start); + let end = projection_offset_to_body_unicode_index(projection, range.end); + ProjectionInvalidation::body_text(frontier_before, frontier_after, start, end.saturating_sub(start)) + }, + EditorSemanticCommand::SetParagraphStyle { paragraph, .. } => { + let paragraph_ix = projection + .ids + .paragraph_ids + .iter() + .position(|id| id == paragraph) + .unwrap_or_default(); + ProjectionInvalidation::body_style( + frontier_before, + frontier_after, + paragraph_boundary_unicode_index(projection, paragraph_ix), + 1, + ) + }, + EditorSemanticCommand::SetRunStyles { paragraph, range, .. } => { + let paragraph_ix = projection + .ids + .paragraph_ids + .iter() + .position(|id| id == paragraph) + .unwrap_or_default(); + let start = projection_offset_to_body_unicode_index( + projection, + DocumentOffset { + paragraph: paragraph_ix, + byte: range.start, + }, + ); + ProjectionInvalidation::body_style(frontier_before, frontier_after, start, range.end.saturating_sub(range.start)) + }, + _ => ProjectionInvalidation::full_rebuild(frontier_before, frontier_after, "editor_structural_projection_fallback"), + } +} + +fn insert_projection_object_block(doc: &LoroDoc, block_id: flowstate_document::BlockId, block_ix: usize, input: &InputBlock) -> Result { + if matches!(input, InputBlock::Paragraph(_)) { + tracing::warn!( + block_ix, + ?block_id, + "skipping InsertBlock for paragraph payload; paragraph edits must use text/paragraph semantic commands" + ); + return Ok(false); + } + + let body = body_text(doc); + if object_loro_block_by_projected_id(doc, &body, block_id).is_some() { + tracing::warn!(block_ix, ?block_id, "skipping InsertBlock because the Loro object block already exists"); + return Ok(false); + } + let Some(unicode_index) = object_insert_unicode_pos_for_projection_block(&body, block_ix) else { + tracing::warn!( + block_ix, + ?block_id, + "skipping InsertBlock because no Loro insertion point maps to the projection block index" + ); + return Ok(false); + }; + insert_input_object_block(doc, unicode_index, block_id, input)?; + doc.commit(); + Ok(true) +} + +fn insert_input_object_block(doc: &LoroDoc, unicode_index: usize, block_id: flowstate_document::BlockId, input: &InputBlock) -> Result<()> { + match input { + InputBlock::Image(image) => insert_image_block_with_id(doc, unicode_index, block_id, image), + InputBlock::Equation(equation) => insert_equation_block_with_id(doc, unicode_index, block_id, equation), + InputBlock::Table(table) => insert_table_block_with_id(doc, unicode_index, block_id, table), + InputBlock::Paragraph(_) => Ok(()), + } +} + +fn replace_projection_object_block( + doc: &LoroDoc, + projection: &DocumentProjection, + block_id: Option, + block_ix: usize, + after: &InputBlock, +) -> Result { + if matches!(after, InputBlock::Paragraph(_)) { + tracing::warn!( + block_ix, + "skipping ReplaceBlock for paragraph payload; paragraph edits must use text/paragraph semantic commands" + ); + return Ok(false); + } + if block_id.is_none() && projection.blocks.get(block_ix).is_none() { + tracing::warn!(block_ix, "skipping ReplaceBlock because the projection block index is out of range"); + return Ok(false); + } + + let body = body_text(doc); + let block = block_id + .and_then(|block_id| object_loro_block_by_projected_id(doc, &body, block_id).map(|(_, block, _)| block)) + .or_else(|| { + projection + .ids + .block_ids + .get(block_ix) + .and_then(|block_id| object_loro_block_by_projected_id(doc, &body, *block_id).map(|(_, block, _)| block)) + }) + .or_else(|| { + let anchor_pos = object_unicode_pos_for_projection_block(&body, block_ix)?; + object_loro_block_at_unicode_pos(doc, &body, anchor_pos) + }); + let Some(block) = block else { + tracing::warn!(block_ix, "skipping ReplaceBlock because no Loro object block maps to the projected block"); + return Ok(false); + }; + + match after { + InputBlock::Image(image) => replace_image_block_from_input(doc, &block, image)?, + InputBlock::Equation(equation) => replace_equation_block_from_input(doc, &block, equation)?, + InputBlock::Table(table) => { + tracing::warn!( + block_ix, + "applying coarse structured table ReplaceBlock; editor should emit finer table operations later" + ); + replace_table_block_from_input(doc, &block, table)?; + }, + InputBlock::Paragraph(_) => unreachable!("paragraph payload was handled above"), + } + doc.commit(); + Ok(true) +} + +fn set_projection_table_column_width( + doc: &LoroDoc, + table_block_id: flowstate_document::BlockId, + column_ix: usize, + width: &InputTableColumnWidth, +) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column width command because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let Some(column_order) = child_movable_list(&table, "column_order") else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column width command because the table has no column order" + ); + return Ok(false); + }; + let column_ids = movable_list_strings(&column_order); + let Some(column_id) = column_ids.get(column_ix) else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column width command because the column index is out of range" + ); + return Ok(false); + }; + let Some(columns_by_id) = child_map(&table, "columns_by_id") else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column width command because the table has no columns map" + ); + return Ok(false); + }; + let column = columns_by_id.ensure_mergeable_map(column_id)?; + write_table_column_width(&column, width)?; + doc.commit(); + Ok(true) +} + +fn insert_projection_table_row(doc: &LoroDoc, table_block_id: flowstate_document::BlockId, row_ix: usize, row: &InputTableRow) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + row_ix, + "skipping table row insert because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let row_order = table.ensure_mergeable_movable_list("row_order")?; + let column_order = table.ensure_mergeable_movable_list("column_order")?; + let rows_by_id = table.ensure_mergeable_map("rows_by_id")?; + let cells_by_id = table.ensure_mergeable_map("cells_by_id")?; + let column_ids = movable_list_strings(&column_order); + if column_ids.is_empty() { + tracing::warn!(?table_block_id, row_ix, "skipping table row insert because the table has no columns"); + return Ok(false); + } + + let row_id = format!("row.{}", Uuid::new_v4().as_u128()); + row_order.insert(row_ix.min(row_order.len()), row_id.as_str())?; + let row_map = rows_by_id.ensure_mergeable_map(&row_id)?; + row_map.insert("id", row_id.as_str())?; + row_map.insert("container_id", row_map.id().to_string())?; + row_map.ensure_mergeable_map("attrs")?; + + let empty_cell = empty_input_table_cell(); + for (column_ix, column_id) in column_ids.iter().enumerate() { + let cell_id = format!("{row_id}.cell.{}", Uuid::new_v4().as_u128()); + let cell_map = cells_by_id.ensure_mergeable_map(&cell_id)?; + let cell = row.cells.get(column_ix).unwrap_or(&empty_cell); + write_table_cell_map_from_input(doc, &cell_map, &cell_id, &row_id, column_id, cell)?; + } + doc.commit(); + Ok(true) +} + +fn delete_projection_table_row(doc: &LoroDoc, table_block_id: flowstate_document::BlockId, row_ix: usize) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + row_ix, + "skipping table row delete because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let Some(row_order) = child_movable_list(&table, "row_order") else { + tracing::warn!(?table_block_id, row_ix, "skipping table row delete because the table has no row order"); + return Ok(false); + }; + let row_ids = movable_list_strings(&row_order); + let Some(row_id) = row_ids.get(row_ix) else { + tracing::warn!(?table_block_id, row_ix, "skipping table row delete because the row index is out of range"); + return Ok(false); + }; + let row_id = row_id.clone(); + row_order.delete(row_ix, 1)?; + if let Some(rows_by_id) = child_map(&table, "rows_by_id") { + rows_by_id.delete(&row_id)?; + } + if let Some(cells_by_id) = child_map(&table, "cells_by_id") { + for cell_id in map_keys(&cells_by_id) { + let delete_cell = child_map(&cells_by_id, &cell_id) + .and_then(|cell| map_string_opt(&cell, "row_id")) + .as_deref() + == Some(row_id.as_str()); + if delete_cell { + cells_by_id.delete(&cell_id)?; + } + } + } + doc.commit(); + Ok(true) +} + +fn move_projection_table_axis( + doc: &LoroDoc, + table_block_id: flowstate_document::BlockId, + order_key: &'static str, + from_ix: usize, + to_ix: usize, +) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + order_key, + from_ix, + to_ix, + "skipping table move because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let Some(order) = child_movable_list(&table, order_key) else { + tracing::warn!( + ?table_block_id, + order_key, + from_ix, + to_ix, + "skipping table move because its order list is missing" + ); + return Ok(false); + }; + if from_ix >= order.len() || to_ix >= order.len() || from_ix == to_ix { + return Ok(false); + } + order.mov(from_ix, to_ix)?; + doc.commit(); + Ok(true) +} + +fn insert_projection_table_column( + doc: &LoroDoc, + table_block_id: flowstate_document::BlockId, + column_ix: usize, + width: &InputTableColumnWidth, + cells: &[InputTableCell], +) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column insert because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let row_order = table.ensure_mergeable_movable_list("row_order")?; + let column_order = table.ensure_mergeable_movable_list("column_order")?; + let rows_by_id = table.ensure_mergeable_map("rows_by_id")?; + let columns_by_id = table.ensure_mergeable_map("columns_by_id")?; + let cells_by_id = table.ensure_mergeable_map("cells_by_id")?; + table.insert("container_id", table.id().to_string())?; + table.insert("row_order_container_id", row_order.id().to_string())?; + table.insert("column_order_container_id", column_order.id().to_string())?; + table.insert("rows_container_id", rows_by_id.id().to_string())?; + table.insert("columns_container_id", columns_by_id.id().to_string())?; + table.insert("cells_container_id", cells_by_id.id().to_string())?; + let row_ids = movable_list_strings(&row_order); + if row_ids.is_empty() { + tracing::warn!(?table_block_id, column_ix, "skipping table column insert because the table has no rows"); + return Ok(false); + } + + let column_id = format!("column.{}", Uuid::new_v4().as_u128()); + column_order.insert(column_ix.min(column_order.len()), column_id.as_str())?; + let column = columns_by_id.ensure_mergeable_map(&column_id)?; + column.insert("id", column_id.as_str())?; + column.insert("container_id", column.id().to_string())?; + column.ensure_mergeable_map("attrs")?; + write_table_column_width(&column, width)?; + + let empty_cell = empty_input_table_cell(); + for (row_ix, row_id) in row_ids.iter().enumerate() { + let cell_id = format!("{row_id}.cell.{}", Uuid::new_v4().as_u128()); + let cell_map = cells_by_id.ensure_mergeable_map(&cell_id)?; + let cell = cells.get(row_ix).unwrap_or(&empty_cell); + write_table_cell_map_from_input(doc, &cell_map, &cell_id, row_id, &column_id, cell)?; + } + doc.commit(); + Ok(true) +} + +fn delete_projection_table_column(doc: &LoroDoc, table_block_id: flowstate_document::BlockId, column_ix: usize) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column delete because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let Some(column_order) = child_movable_list(&table, "column_order") else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column delete because the table has no column order" + ); + return Ok(false); + }; + let column_ids = movable_list_strings(&column_order); + let Some(column_id) = column_ids.get(column_ix) else { + tracing::warn!( + ?table_block_id, + column_ix, + "skipping table column delete because the column index is out of range" + ); + return Ok(false); + }; + let column_id = column_id.clone(); + column_order.delete(column_ix, 1)?; + if let Some(columns_by_id) = child_map(&table, "columns_by_id") { + columns_by_id.delete(&column_id)?; + } + if let Some(cells_by_id) = child_map(&table, "cells_by_id") { + for cell_id in map_keys(&cells_by_id) { + let delete_cell = child_map(&cells_by_id, &cell_id) + .and_then(|cell| map_string_opt(&cell, "column_id")) + .as_deref() + == Some(column_id.as_str()); + if delete_cell { + cells_by_id.delete(&cell_id)?; + } + } + } + doc.commit(); + Ok(true) +} + +fn replace_projection_table_cell( + doc: &LoroDoc, + table_block_id: flowstate_document::BlockId, + row_ix: usize, + cell_ix: usize, + cell: &InputTableCell, +) -> Result { + let Some(table) = projection_table_map_by_block_id(doc, table_block_id) else { + tracing::warn!( + ?table_block_id, + row_ix, + cell_ix, + "skipping table cell replace because no Loro table maps to the projected block id" + ); + return Ok(false); + }; + let Some(row_order) = child_movable_list(&table, "row_order") else { + tracing::warn!( + ?table_block_id, + row_ix, + cell_ix, + "skipping table cell replace because the table has no row order" + ); + return Ok(false); + }; + let Some(column_order) = child_movable_list(&table, "column_order") else { + tracing::warn!( + ?table_block_id, + row_ix, + cell_ix, + "skipping table cell replace because the table has no column order" + ); + return Ok(false); + }; + let row_ids = movable_list_strings(&row_order); + let column_ids = movable_list_strings(&column_order); + let Some(row_id) = row_ids.get(row_ix) else { + tracing::warn!( + ?table_block_id, + row_ix, + cell_ix, + "skipping table cell replace because the row index is out of range" + ); + return Ok(false); + }; + let Some(column_id) = column_ids.get(cell_ix) else { + tracing::warn!( + ?table_block_id, + row_ix, + cell_ix, + "skipping table cell replace because the cell column index is out of range" + ); + return Ok(false); + }; + let cells_by_id = table.ensure_mergeable_map("cells_by_id")?; + let cell_id = + table_cell_id_by_row_column(&cells_by_id, row_id, column_id).unwrap_or_else(|| format!("{row_id}.cell.{}", Uuid::new_v4().as_u128())); + let cell_map = cells_by_id.ensure_mergeable_map(&cell_id)?; + update_table_cell_map_from_input(doc, &cell_map, &cell_id, row_id, column_id, cell)?; + doc.commit(); + Ok(true) +} + +fn set_projection_table_cell_span( + doc: &LoroDoc, + table_block_id: flowstate_document::BlockId, + row_ix: usize, + cell_ix: usize, + row_span: u16, + column_span: u16, +) -> Result { + let Some(cell) = projection_table_cell_map(doc, table_block_id, row_ix, cell_ix) else { + tracing::warn!( + ?table_block_id, + row_ix, + cell_ix, + "skipping table span command because the Loro cell is missing" + ); + return Ok(false); + }; + cell.insert("row_span", i64::from(row_span.max(1)))?; + cell.insert("column_span", i64::from(column_span.max(1)))?; + doc.commit(); + Ok(true) +} + +fn projection_table_cell_map(doc: &LoroDoc, table_block_id: flowstate_document::BlockId, row_ix: usize, cell_ix: usize) -> Option { + let table = projection_table_map_by_block_id(doc, table_block_id)?; + let row_id = movable_list_strings(&child_movable_list(&table, "row_order")?) + .get(row_ix)? + .clone(); + let column_id = movable_list_strings(&child_movable_list(&table, "column_order")?) + .get(cell_ix)? + .clone(); + // §28: resolve the cell map directly via the table's stored `cells_container_id`, + // falling back to map-key traversal when the id is missing/unresolvable. + let cells_by_id = child_map_by_container_id(doc, &table, "cells_container_id", "cells_by_id")?; + let cell_id = table_cell_id_by_row_column(&cells_by_id, &row_id, &column_id)?; + child_map(&cells_by_id, &cell_id) +} + +fn replace_projection_equation_source_range( + doc: &LoroDoc, + equation_block_id: flowstate_document::BlockId, + range: &std::ops::Range, + replacement: &str, +) -> Result { + let body = body_text(doc); + let Some((_, block, _)) = object_loro_block_by_projected_id(doc, &body, equation_block_id) else { + tracing::warn!( + ?equation_block_id, + ?range, + "skipping equation source edit because no Loro equation maps to the projected block id" + ); + return Ok(false); + }; + if map_string_opt(&block, "kind").as_deref() != Some("equation") { + tracing::warn!( + ?equation_block_id, + ?range, + "skipping equation source edit because the projected block is not an equation" + ); + return Ok(false); + } + let source_flow_id = map_string_opt(&block, "source_flow_id").unwrap_or_else(|| nested_flow_id("equation_source")); + block.insert("source_flow_id", source_flow_id.as_str())?; + let source_flow = ensure_flow(doc, &source_flow_id, "equation_source")?; + // §28: resolve the source flow's text via its stored `text_container_id`. + let source_text = flow_text(doc, &source_flow)?; + let before = source_text.to_string(); + let Some(start) = byte_index_to_unicode_index(&before, range.start) else { + tracing::warn!( + ?equation_block_id, + ?range, + "skipping equation source edit because the start byte is not a source boundary" + ); + return Ok(false); + }; + let Some(end) = byte_index_to_unicode_index(&before, range.end) else { + tracing::warn!( + ?equation_block_id, + ?range, + "skipping equation source edit because the end byte is not a source boundary" + ); + return Ok(false); + }; + if end < start { + tracing::warn!(?equation_block_id, ?range, "skipping equation source edit because the range is inverted"); + return Ok(false); + } + if end > start { + source_text.delete(start, end - start)?; + } + if !replacement.is_empty() { + source_text.insert(start, replacement)?; + } + doc.commit(); + Ok(true) +} + +fn replace_projection_image_alt_text(doc: &LoroDoc, image_block_id: flowstate_document::BlockId, text: &str) -> Result { + let body = body_text(doc); + let Some((_, block, _)) = object_loro_block_by_projected_id(doc, &body, image_block_id) else { + tracing::warn!( + ?image_block_id, + "skipping image alt text edit because no Loro image maps to the projected block id" + ); + return Ok(false); + }; + if map_string_opt(&block, "kind").as_deref() != Some("image") { + tracing::warn!( + ?image_block_id, + "skipping image alt text edit because the projected block is not an image" + ); + return Ok(false); + } + let alt_flow_id = map_string_opt(&block, "alt_text_flow_id").unwrap_or_else(|| nested_flow_id("image_alt")); + block.insert("alt_text_flow_id", alt_flow_id.as_str())?; + let alt_flow = ensure_flow(doc, &alt_flow_id, "alt_text")?; + // §28: resolve the alt-text flow's text via its stored `text_container_id`. + replace_text_incrementally(&flow_text(doc, &alt_flow)?, text)?; + doc.commit(); + Ok(true) +} + +fn replace_projection_image_caption( + doc: &LoroDoc, + image_block_id: flowstate_document::BlockId, + caption: Option<&InputParagraph>, +) -> Result { + let body = body_text(doc); + let Some((_, block, _)) = object_loro_block_by_projected_id(doc, &body, image_block_id) else { + tracing::warn!( + ?image_block_id, + "skipping image caption edit because no Loro image maps to the projected block id" + ); + return Ok(false); + }; + if map_string_opt(&block, "kind").as_deref() != Some("image") { + return Ok(false); + } + if let Some(caption) = caption { + let caption_flow_id = map_string_opt(&block, "caption_flow_id").unwrap_or_else(|| nested_flow_id("image_caption")); + block.insert("caption_flow_id", caption_flow_id.as_str())?; + let caption_flow = ensure_flow(doc, &caption_flow_id, "caption")?; + let text = caption_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let desired = format!( + "{SENTINEL_NEWLINE}{}", + caption + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::() + ); + replace_text_incrementally(&text, &desired)?; + let len = text.len_unicode(); + for key in [ + MARK_PARAGRAPH_STYLE, + MARK_RUN_SEMANTIC_STYLE, + MARK_HIGHLIGHT_STYLE, + MARK_DIRECT_UNDERLINE, + MARK_STRIKETHROUGH, + ] { + text.unmark(0..len, key)?; + } + text.mark(0..1, MARK_PARAGRAPH_STYLE, paragraph_style_value(caption.style))?; + let mut cursor = 1usize; + for run in &caption.runs { + let run_len = run.text.chars().count(); + if run_len > 0 { + mark_run_styles(&text, cursor..cursor + run_len, run.styles)?; + } + cursor += run_len; + } + } else { + block.delete("caption_flow_id")?; + } + doc.commit(); + Ok(true) +} + +fn set_projection_image_layout( + doc: &LoroDoc, + image_block_id: flowstate_document::BlockId, + sizing: &InputImageSizing, + alignment: InputBlockAlignment, +) -> Result { + let body = body_text(doc); + let Some((_, block, _)) = object_loro_block_by_projected_id(doc, &body, image_block_id) else { + tracing::warn!( + ?image_block_id, + "skipping image layout edit because no Loro image maps to the projected block id" + ); + return Ok(false); + }; + if map_string_opt(&block, "kind").as_deref() != Some("image") { + tracing::warn!(?image_block_id, "skipping image layout edit because the projected block is not an image"); + return Ok(false); + } + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("alignment", alignment_name(alignment))?; + write_image_sizing_attrs(&attrs, sizing)?; + doc.commit(); + Ok(true) +} + +fn byte_index_to_unicode_index(value: &str, byte: usize) -> Option { + (byte <= value.len() && value.is_char_boundary(byte)).then(|| value[..byte].chars().count()) +} + +fn table_cell_id_by_row_column(cells_by_id: &LoroMap, row_id: &str, column_id: &str) -> Option { + map_keys(cells_by_id).into_iter().find(|cell_id| { + child_map(cells_by_id, cell_id).is_some_and(|cell| { + map_string_opt(&cell, "row_id").as_deref() == Some(row_id) && map_string_opt(&cell, "column_id").as_deref() == Some(column_id) + }) + }) +} + +fn empty_input_table_cell() -> InputTableCell { + InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Normal, + runs: Vec::new(), + })], + row_span: 1, + col_span: 1, + } +} + +fn projection_table_map_by_block_id(doc: &LoroDoc, table_block_id: flowstate_document::BlockId) -> Option { + let body = body_text(doc); + let (_, block, _) = object_loro_block_by_projected_id(doc, &body, table_block_id)?; + if map_string_opt(&block, "kind").as_deref() != Some("table") { + return None; + } + child_map(&block, "table") +} + +fn delete_projection_object_block(doc: &LoroDoc, block_id: flowstate_document::BlockId) -> Result { + let body = body_text(doc); + let Some((key, _, anchor_pos)) = object_loro_block_by_projected_id(doc, &body, block_id) else { + tracing::warn!( + ?block_id, + "skipping DeleteBlock because no Loro object block maps to the projected block id" + ); + return Ok(false); + }; + if body.to_string().chars().nth(anchor_pos) != Some(OBJECT_REPLACEMENT) { + tracing::warn!( + ?block_id, + anchor_pos, + "skipping DeleteBlock because the Loro object anchor is no longer live" + ); + return Ok(false); + } + body + .delete(anchor_pos, 1) + .context("deleting object placeholder from body flow")?; + doc + .get_map(ROOT) + .ensure_mergeable_map(BLOCKS_BY_ID)? + .delete(&key) + .context("deleting object block metadata")?; + doc.commit(); + Ok(true) +} + +fn move_projection_object_block(doc: &LoroDoc, block_id: flowstate_document::BlockId, new_block_ix: usize) -> Result { + let body = body_text(doc); + let Some((_, block, anchor_pos)) = object_loro_block_by_projected_id(doc, &body, block_id) else { + tracing::warn!( + ?block_id, + new_block_ix, + "skipping MoveBlock because no Loro object block maps to the projected block id" + ); + return Ok(false); + }; + if body.to_string().chars().nth(anchor_pos) != Some(OBJECT_REPLACEMENT) { + tracing::warn!( + ?block_id, + anchor_pos, + "skipping MoveBlock because the Loro object anchor is no longer live" + ); + return Ok(false); + } + body + .delete(anchor_pos, 1) + .context("deleting object placeholder before move")?; + let insert_pos = object_insert_unicode_pos_for_projection_block(&body, new_block_ix).unwrap_or_else(|| body.len_unicode()); + body + .insert(insert_pos, &OBJECT_REPLACEMENT.to_string()) + .context("reinserting object placeholder after move")?; + if let Some(cursor) = body.get_cursor(insert_pos, Side::Left) { + block.insert("anchor_cursor", cursor.encode())?; + } + doc.commit(); + Ok(true) +} + +fn object_unicode_pos_for_projection_block(body: &LoroText, target_block_ix: usize) -> Option { + let mut block_ix = 0_usize; + let mut current_paragraph_has_text = false; + let mut seen_sentinel = false; + + for (unicode_pos, ch) in body.to_string().chars().enumerate() { + match ch { + '\n' => { + if seen_sentinel { + if block_ix == target_block_ix { + return None; + } + block_ix += 1; + } else { + seen_sentinel = true; + } + current_paragraph_has_text = false; + }, + OBJECT_REPLACEMENT => { + if current_paragraph_has_text { + if block_ix == target_block_ix { + return None; + } + block_ix += 1; + current_paragraph_has_text = false; + } + if block_ix == target_block_ix { + return Some(unicode_pos); + } + block_ix += 1; + }, + _ => current_paragraph_has_text = true, + } + } + None +} + +fn object_insert_unicode_pos_for_projection_block(body: &LoroText, target_block_ix: usize) -> Option { + let mut block_ix = 0_usize; + let mut current_paragraph_has_text = false; + let mut seen_sentinel = false; + let mut last_pos = 0_usize; + + for (unicode_pos, ch) in body.to_string().chars().enumerate() { + last_pos = unicode_pos + 1; + match ch { + '\n' => { + if seen_sentinel { + if block_ix >= target_block_ix { + return Some(unicode_pos); + } + block_ix += 1; + } else { + seen_sentinel = true; + } + current_paragraph_has_text = false; + }, + OBJECT_REPLACEMENT => { + if current_paragraph_has_text { + if block_ix >= target_block_ix { + return Some(unicode_pos); + } + block_ix += 1; + current_paragraph_has_text = false; + } + if block_ix >= target_block_ix { + return Some(unicode_pos); + } + block_ix += 1; + }, + _ => current_paragraph_has_text = true, + } + } + + if current_paragraph_has_text { + if block_ix >= target_block_ix { + return Some(last_pos); + } + block_ix += 1; + } + (block_ix <= target_block_ix).then_some(last_pos) +} + +fn object_loro_block_by_projected_id(doc: &LoroDoc, body: &LoroText, block_id: flowstate_document::BlockId) -> Option<(String, LoroMap, usize)> { + let root = doc.get_map(ROOT); + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID).ok()?; + let body_snapshot = body.to_string(); + for key in map_keys(&blocks) { + if loro_id_u128(&key) != block_id.0 { + continue; + } + let block = child_map(&blocks, &key)?; + if map_string_opt(&block, "kind").as_deref() == Some("paragraph") { + return None; + } + let anchor_pos = live_object_cursor_pos(doc, &body_snapshot, &block, "anchor_cursor")?; + return Some((key, block, anchor_pos)); + } + for key in map_keys(&blocks) { + let block = child_map(&blocks, &key)?; + if map_string_opt(&block, "kind").as_deref() == Some("paragraph") { + continue; + } + if map_string_opt(&block, "id").is_some_and(|id| loro_id_u128(&id) == block_id.0) { + let anchor_pos = live_object_cursor_pos(doc, &body_snapshot, &block, "anchor_cursor")?; + return Some((key, block, anchor_pos)); + } + } + None +} + +fn object_loro_block_at_unicode_pos(doc: &LoroDoc, body: &LoroText, unicode_pos: usize) -> Option { + let root = doc.get_map(ROOT); + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID).ok()?; + let body_snapshot = body.to_string(); + for key in map_keys(&blocks) { + let block = child_map(&blocks, &key)?; + if map_string_opt(&block, "kind").as_deref() == Some("paragraph") { + continue; + } + if live_object_cursor_pos(doc, &body_snapshot, &block, "anchor_cursor") == Some(unicode_pos) { + return Some(block); + } + } + None +} + +fn loro_id_u128(id: &str) -> u128 { + if let Some(value) = id + .rsplit('.') + .next() + .and_then(|suffix| suffix.parse::().ok()) + { + return value; + } + Uuid::new_v5(&Uuid::NAMESPACE_OID, id.as_bytes()).as_u128() +} + +fn replace_image_block_from_input(doc: &LoroDoc, block: &LoroMap, image: &flowstate_document::InputImageBlock) -> Result<()> { + block.insert("kind", "image")?; + block.insert("asset_id", image.asset_id.0.to_string())?; + copy_asset_metadata_to_image_block(doc, block, image.asset_id.0)?; + + let alt_flow_id = map_string_opt(block, "alt_text_flow_id").unwrap_or_else(|| nested_flow_id("image_alt")); + block.insert("alt_text_flow_id", alt_flow_id.as_str())?; + let alt_flow = ensure_flow(doc, &alt_flow_id, "alt_text")?; + replace_text(&alt_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?, image.alt_text.as_ref())?; + + if let Some(caption) = &image.caption { + let caption_flow_id = map_string_opt(block, "caption_flow_id").unwrap_or_else(|| nested_flow_id("image_caption")); + block.insert("caption_flow_id", caption_flow_id.as_str())?; + let caption_flow = ensure_flow(doc, &caption_flow_id, "caption")?; + let caption_text = caption_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + replace_text(&caption_text, SENTINEL_NEWLINE)?; + append_input_paragraph_text_only(&caption_text, caption)?; + } else { + block.delete("caption_flow_id")?; + } + + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("alignment", alignment_name(image.alignment))?; + write_image_sizing_attrs(&attrs, &image.sizing)?; + Ok(()) +} + +fn copy_asset_metadata_to_image_block(doc: &LoroDoc, block: &LoroMap, asset_id: u128) -> Result<()> { + let root = doc.get_map(ROOT); + let Some(assets) = child_map(&root, flowstate_document::loro_schema::ASSETS_BY_ID) else { + return Ok(()); + }; + let Some(asset) = child_map(&assets, &asset_id.to_string()) else { + return Ok(()); + }; + for field in ["content_hash", "mime_type", "byte_length"] { + if let Some(ValueOrContainer::Value(value)) = asset.get(field) { + block.insert(field, value)?; + } + } + Ok(()) +} + +fn refresh_image_asset_metadata(doc: &LoroDoc) -> Result<()> { + let root = doc.get_map(ROOT); + let Some(blocks) = child_map(&root, BLOCKS_BY_ID) else { + return Ok(()); + }; + for key in map_keys(&blocks) { + let Some(block) = child_map(&blocks, &key) else { + continue; + }; + if map_string_opt(&block, "kind").as_deref() != Some("image") { + continue; + } + let Some(asset_id) = map_string_opt(&block, "asset_id").and_then(|id| id.parse().ok()) else { + continue; + }; + copy_asset_metadata_to_image_block(doc, &block, asset_id)?; + } + Ok(()) +} + +fn replace_equation_block_from_input(doc: &LoroDoc, block: &LoroMap, equation: &flowstate_document::InputEquationBlock) -> Result<()> { + block.insert("kind", "equation")?; + let source_flow_id = map_string_opt(block, "source_flow_id").unwrap_or_else(|| nested_flow_id("equation_source")); + block.insert("source_flow_id", source_flow_id.as_str())?; + let source_flow = ensure_flow(doc, &source_flow_id, "equation_source")?; + replace_text(&source_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?, &equation.source)?; + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("syntax", "latex")?; + attrs.insert("display", equation_display_name(equation.display))?; + Ok(()) +} + +fn replace_table_block_from_input(doc: &LoroDoc, block: &LoroMap, table: &InputTableBlock) -> Result<()> { + block.insert("kind", "table")?; + let table_map = block.ensure_mergeable_map("table")?; + write_table_map_from_input(doc, &table_map, table, &table_id()) +} + +fn write_image_sizing_attrs(attrs: &LoroMap, sizing: &InputImageSizing) -> Result<()> { + attrs.delete("width_px")?; + attrs.delete("height_px")?; + match sizing { + InputImageSizing::Intrinsic => attrs.insert("sizing", "intrinsic")?, + InputImageSizing::FitWidth => attrs.insert("sizing", "fit_width")?, + InputImageSizing::Fixed { width_px, height_px } => { + attrs.insert("sizing", "fixed")?; + attrs.insert("width_px", i64::from(*width_px))?; + if let Some(height_px) = *height_px { + attrs.insert("height_px", i64::from(height_px))?; + } + }, + }; + Ok(()) +} + +fn write_table_map_from_input(doc: &LoroDoc, table_map: &LoroMap, table: &InputTableBlock, prefix: &str) -> Result<()> { + table_map.insert("header_row", table.style.header_row)?; + let row_order = table_map.ensure_mergeable_movable_list("row_order")?; + let column_order = table_map.ensure_mergeable_movable_list("column_order")?; + let rows_by_id = table_map.ensure_mergeable_map("rows_by_id")?; + let columns_by_id = table_map.ensure_mergeable_map("columns_by_id")?; + let cells_by_id = table_map.ensure_mergeable_map("cells_by_id")?; + table_map.insert("container_id", table_map.id().to_string())?; + table_map.insert("row_order_container_id", row_order.id().to_string())?; + table_map.insert("column_order_container_id", column_order.id().to_string())?; + table_map.insert("rows_container_id", rows_by_id.id().to_string())?; + table_map.insert("columns_container_id", columns_by_id.id().to_string())?; + table_map.insert("cells_container_id", cells_by_id.id().to_string())?; + clear_movable_list(&row_order)?; + clear_movable_list(&column_order)?; + clear_map(&rows_by_id)?; + clear_map(&columns_by_id)?; + clear_map(&cells_by_id)?; + + let column_count = table.column_widths.len().max( + table + .rows + .iter() + .map(|row| { + row + .cells + .iter() + .map(|cell| usize::from(cell.col_span.max(1))) + .sum() + }) + .max() + .unwrap_or(0), + ); + let mut column_ids = Vec::with_capacity(column_count); + for column_ix in 0..column_count { + let column_id = format!("{prefix}.column.{column_ix}"); + column_order.push(column_id.as_str())?; + column_ids.push(column_id.clone()); + let column = columns_by_id.ensure_mergeable_map(&column_id)?; + column.insert("id", column_id.as_str())?; + column.insert("container_id", column.id().to_string())?; + let attrs = column.ensure_mergeable_map("attrs")?; + column.insert("attrs_container_id", attrs.id().to_string())?; + write_table_column_width( + &column, + table + .column_widths + .get(column_ix) + .unwrap_or(&InputTableColumnWidth::Auto), + )?; + } + + for (row_ix, row) in table.rows.iter().enumerate() { + let row_id = format!("{prefix}.row.{row_ix}"); + row_order.push(row_id.as_str())?; + let row_map = rows_by_id.ensure_mergeable_map(&row_id)?; + row_map.insert("id", row_id.as_str())?; + row_map.insert("container_id", row_map.id().to_string())?; + let attrs = row_map.ensure_mergeable_map("attrs")?; + row_map.insert("attrs_container_id", attrs.id().to_string())?; + let mut column_ix = 0_usize; + for (cell_ix, cell) in row.cells.iter().enumerate() { + let Some(column_id) = column_ids.get(column_ix) else { + break; + }; + let cell_id = format!("{row_id}.cell.{cell_ix}"); + let cell_map = cells_by_id.ensure_mergeable_map(&cell_id)?; + write_table_cell_map_from_input(doc, &cell_map, &cell_id, &row_id, column_id, cell)?; + column_ix += usize::from(cell.col_span.max(1)); + } + } + Ok(()) +} + +fn write_table_cell_map_from_input( + doc: &LoroDoc, + cell_map: &LoroMap, + cell_id: &str, + row_id: &str, + column_id: &str, + cell: &InputTableCell, +) -> Result<()> { + cell_map.insert("id", cell_id)?; + cell_map.insert("container_id", cell_map.id().to_string())?; + cell_map.insert("row_id", row_id)?; + cell_map.insert("column_id", column_id)?; + cell_map.insert("row_span", i64::from(cell.row_span))?; + cell_map.insert("column_span", i64::from(cell.col_span))?; + let attrs = cell_map.ensure_mergeable_map("attrs")?; + cell_map.insert("attrs_container_id", attrs.id().to_string())?; + let nested_table_ids = cell_map.ensure_mergeable_movable_list("nested_table_ids")?; + let nested_tables_by_id = cell_map.ensure_mergeable_map("nested_tables_by_id")?; + cell_map.insert("nested_table_order_container_id", nested_table_ids.id().to_string())?; + cell_map.insert("nested_tables_container_id", nested_tables_by_id.id().to_string())?; + clear_movable_list(&nested_table_ids)?; + clear_map(&nested_tables_by_id)?; + let flow_id = format!("{cell_id}.flow"); + cell_map.insert("flow_id", flow_id.as_str())?; + let flow = ensure_flow(doc, &flow_id, "table_cell")?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + cell_map.insert("flow_container_id", flow.id().to_string())?; + cell_map.insert("text_container_id", text.id().to_string())?; + replace_text(&text, SENTINEL_NEWLINE)?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, 0_i64)?; + for (block_ix, cell_block) in cell.blocks.iter().enumerate() { + match cell_block { + InputTableCellBlock::Paragraph(paragraph) => append_input_paragraph_text_only(&text, paragraph)?, + InputTableCellBlock::Table(nested) => { + let pos = text.len_unicode(); + text.insert(pos, &OBJECT_REPLACEMENT.to_string())?; + let nested_table_id = format!("{cell_id}.nested_table.{block_ix}"); + nested_table_ids.push(nested_table_id.as_str())?; + let nested_map = nested_tables_by_id.ensure_mergeable_map(&nested_table_id)?; + nested_map.insert("id", nested_table_id.as_str())?; + nested_map.insert("container_id", nested_map.id().to_string())?; + nested_map.insert("kind", "table")?; + if let Some(cursor) = text.get_cursor(pos, Side::Left) { + nested_map.insert("anchor_cursor", cursor.encode())?; + } + nested_map.ensure_mergeable_map("attrs")?; + write_table_map_from_input( + doc, + &nested_map.ensure_mergeable_map("table")?, + nested, + &format!("{cell_id}.nested.{block_ix}"), + )?; + }, + } + } + Ok(()) +} + +fn update_table_cell_map_from_input( + doc: &LoroDoc, + cell_map: &LoroMap, + cell_id: &str, + row_id: &str, + column_id: &str, + cell: &InputTableCell, +) -> Result<()> { + if cell + .blocks + .iter() + .any(|block| matches!(block, InputTableCellBlock::Table(_))) + { + tracing::warn!(cell_id, "using full table-cell rebuild fallback for nested table structure"); + return write_table_cell_map_from_input(doc, cell_map, cell_id, row_id, column_id, cell); + } + cell_map.insert("id", cell_id)?; + cell_map.insert("container_id", cell_map.id().to_string())?; + cell_map.insert("row_id", row_id)?; + cell_map.insert("column_id", column_id)?; + cell_map.insert("row_span", i64::from(cell.row_span))?; + cell_map.insert("column_span", i64::from(cell.col_span))?; + let attrs = cell_map.ensure_mergeable_map("attrs")?; + cell_map.insert("attrs_container_id", attrs.id().to_string())?; + let flow_id = map_string_opt(cell_map, "flow_id").unwrap_or_else(|| format!("{cell_id}.flow")); + cell_map.insert("flow_id", flow_id.as_str())?; + let flow = ensure_flow(doc, &flow_id, "table_cell")?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + cell_map.insert("flow_container_id", flow.id().to_string())?; + cell_map.insert("text_container_id", text.id().to_string())?; + + let paragraphs = cell + .blocks + .iter() + .filter_map(|block| match block { + InputTableCellBlock::Paragraph(paragraph) => Some(paragraph), + InputTableCellBlock::Table(_) => None, + }) + .collect::>(); + let desired = if paragraphs.is_empty() { + SENTINEL_NEWLINE.to_string() + } else { + let mut desired = String::from(SENTINEL_NEWLINE); + for (paragraph_ix, paragraph) in paragraphs.iter().enumerate() { + if paragraph_ix > 0 { + desired.push('\n'); + } + for run in ¶graph.runs { + desired.push_str(&run.text); + } + } + desired + }; + replace_text_incrementally(&text, &desired)?; + let len = text.len_unicode(); + for key in [ + MARK_PARAGRAPH_STYLE, + MARK_RUN_SEMANTIC_STYLE, + MARK_HIGHLIGHT_STYLE, + MARK_DIRECT_UNDERLINE, + MARK_STRIKETHROUGH, + ] { + text.unmark(0..len, key)?; + } + if paragraphs.is_empty() { + text.mark(0..1, MARK_PARAGRAPH_STYLE, paragraph_style_value(ParagraphStyle::Normal))?; + return Ok(()); + } + let mut cursor = 0usize; + for paragraph in paragraphs { + text.mark(cursor..cursor + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(paragraph.style))?; + cursor += 1; + for run in ¶graph.runs { + let run_len = run.text.chars().count(); + if run_len > 0 { + mark_run_styles(&text, cursor..cursor + run_len, run.styles)?; + } + cursor += run_len; + } + } + Ok(()) +} + +fn replace_text_incrementally(text: &LoroText, desired: &str) -> loro::LoroResult<()> { + let current = text.to_string(); + if current == desired { + return Ok(()); + } + let current_chars = current.chars().collect::>(); + let desired_chars = desired.chars().collect::>(); + let prefix = current_chars + .iter() + .zip(&desired_chars) + .take_while(|(left, right)| left == right) + .count(); + let suffix = current_chars + .iter() + .skip(prefix) + .rev() + .zip(desired_chars.iter().skip(prefix).rev()) + .take_while(|(left, right)| left == right) + .count(); + let delete_len = current_chars.len().saturating_sub(prefix + suffix); + if delete_len > 0 { + text.delete(prefix, delete_len)?; + } + let insert_end = desired_chars.len().saturating_sub(suffix); + if insert_end > prefix { + let insert = desired_chars[prefix..insert_end].iter().collect::(); + text.insert(prefix, &insert)?; + } + Ok(()) +} + +fn write_table_column_width(column: &LoroMap, width: &InputTableColumnWidth) -> Result<()> { + column.delete("width_px")?; + column.delete("fraction")?; + match width { + InputTableColumnWidth::Auto => column.insert("width_kind", "auto")?, + InputTableColumnWidth::FixedPx(px) => { + column.insert("width_kind", "fixed_px")?; + column.insert("width_px", i64::from(*px))?; + }, + InputTableColumnWidth::Fraction(fraction) => { + column.insert("width_kind", "fraction")?; + column.insert("fraction", i64::from(*fraction))?; + }, + }; + Ok(()) +} + +fn append_input_paragraph_text_only(text: &LoroText, paragraph: &InputParagraph) -> Result<()> { + let use_existing_sentinel = text.len_unicode() == 1 && text.to_string() == SENTINEL_NEWLINE; + let boundary_pos = if use_existing_sentinel { + 0 + } else { + let pos = text.len_unicode(); + text.insert(pos, "\n")?; + pos + }; + text.mark( + boundary_pos..boundary_pos + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(paragraph.style), + )?; + for run in ¶graph.runs { + if run.text.is_empty() { + continue; + } + let start = text.len_unicode(); + text.insert(start, &run.text)?; + let len = run.text.chars().count(); + mark_run_styles(text, start..start + len, run.styles)?; + } + Ok(()) +} + +fn clear_map(map: &LoroMap) -> loro::LoroResult<()> { + // §29: rebuild/fallback paths use Loro's native container clear instead of a + // manual per-key delete loop. Behavior is identical, just native. + map.clear() +} + +fn clear_movable_list(list: &LoroMovableList) -> loro::LoroResult<()> { + // §29: native clear instead of `delete(0, len)`; identical effect. + list.clear() +} + +fn projection_offset_to_body_unicode_index(projection: &DocumentProjection, offset: flowstate_document::DocumentOffset) -> usize { + ProjectionRuntimeIndex::from_projection(projection) + .body_unicode_for_offset(projection, offset) + .unwrap_or(1) +} + +fn clamp_projection_offset(projection: &DocumentProjection, offset: DocumentOffset) -> DocumentOffset { + let paragraph = offset + .paragraph + .min(projection.paragraphs.len().saturating_sub(1)); + let byte = projection + .paragraphs + .get(paragraph) + .map(flowstate_document::paragraph_text_len) + .unwrap_or_default() + .min(offset.byte); + DocumentOffset { paragraph, byte } +} + +fn paragraph_boundary_unicode_index(projection: &DocumentProjection, paragraph_ix: usize) -> usize { + if paragraph_ix == 0 { + return 0; + } + projection_offset_to_body_unicode_index( + projection, + flowstate_document::DocumentOffset { + paragraph: paragraph_ix, + byte: 0, + }, + ) - 1 +} + +fn join_projection_paragraphs( + doc: &LoroDoc, + projection: &DocumentProjection, + first: flowstate_document::ParagraphId, + second: flowstate_document::ParagraphId, +) -> Result { + let Some(first_ix) = projection + .ids + .paragraph_ids + .iter() + .position(|id| *id == first) + else { + tracing::warn!( + ?first, + ?second, + "skipping JoinParagraphs because the first paragraph id is absent from the supplied projection" + ); + return Ok(false); + }; + let Some(second_ix) = projection + .ids + .paragraph_ids + .iter() + .position(|id| *id == second) + else { + tracing::warn!( + ?first, + ?second, + "skipping JoinParagraphs because the second paragraph id is absent from the supplied projection" + ); + return Ok(false); + }; + if first_ix + 1 != second_ix { + tracing::warn!( + ?first, + ?second, + first_ix, + second_ix, + "skipping JoinParagraphs for non-adjacent paragraphs" + ); + return Ok(false); + } + if !projection_paragraph_blocks_are_adjacent(projection, first_ix, second_ix) { + tracing::warn!( + ?first, + ?second, + first_ix, + second_ix, + "skipping JoinParagraphs because an object block separates the paragraphs and projection offsets are not object-aware", + ); + return Ok(false); + } + + let boundary = paragraph_boundary_unicode_index(projection, second_ix); + let body = body_text(doc); + if !boundary_is_live(&body.to_string(), boundary) { + tracing::warn!( + ?first, + ?second, + boundary, + "skipping JoinParagraphs because the computed Loro boundary is not a live paragraph newline", + ); + return Ok(false); + } + body + .delete(boundary, 1) + .context("deleting joined paragraph boundary from Loro body flow")?; + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &[], "editor_join_paragraphs")?; + doc.commit(); + Ok(true) +} + +fn projection_paragraph_blocks_are_adjacent(projection: &DocumentProjection, first_ix: usize, second_ix: usize) -> bool { + let Some(first_block_ix) = flowstate_document::block_ix_for_paragraph(projection, first_ix) else { + return false; + }; + let Some(second_block_ix) = flowstate_document::block_ix_for_paragraph(projection, second_ix) else { + return false; + }; + second_block_ix == first_block_ix + 1 +} + +fn replace_body_paragraph_span( + doc: &LoroDoc, + projection: &DocumentProjection, + start: Option, + before: &flowstate_document::DocumentSpan, + after: &flowstate_document::DocumentSpan, +) -> Result { + if before.paragraphs.is_empty() && after.paragraphs.is_empty() { + return Ok(false); + } + let start = projection_offset_to_body_unicode_index( + projection, + start.unwrap_or(flowstate_document::DocumentOffset { + paragraph: before.start_paragraph, + byte: 0, + }), + ); + let paragraph_texts = span_paragraph_texts(after); + let replacement = paragraph_texts.join("\n"); + let before_chars = before.text.chars().collect::>(); + let replacement_chars = replacement.chars().collect::>(); + let common_prefix = before_chars + .iter() + .zip(&replacement_chars) + .take_while(|(before, after)| before == after) + .count(); + let max_suffix = before_chars + .len() + .min(replacement_chars.len()) + .saturating_sub(common_prefix); + let common_suffix = before_chars + .iter() + .rev() + .zip(replacement_chars.iter().rev()) + .take(max_suffix) + .take_while(|(before, after)| before == after) + .count(); + let before_changed_len = before_chars + .len() + .saturating_sub(common_prefix + common_suffix); + let replacement_changed_end = replacement_chars.len().saturating_sub(common_suffix); + let replacement_changed = replacement_chars[common_prefix..replacement_changed_end] + .iter() + .collect::(); + let body = body_text(doc); + let start = start.min(body.len_unicode()); + let change_start = start.saturating_add(common_prefix).min(body.len_unicode()); + let change_end = change_start + .saturating_add(before_changed_len) + .min(body.len_unicode()); + if change_end > change_start { + body.delete(change_start, change_end - change_start)?; + } + if !replacement_changed.is_empty() { + body.insert(change_start, &replacement_changed)?; + } + let first_boundary = start.saturating_sub(1); + mark_replacement_span(&body, first_boundary, start, after, ¶graph_texts)?; + let boundaries = replacement_span_boundaries(first_boundary, start, ¶graph_texts); + repair_paragraph_metadata_after_text_flow_edit(doc, &body, &boundaries, "editor_replace_paragraph_span")?; + doc.commit(); + Ok(true) +} + +fn span_paragraph_texts(span: &flowstate_document::DocumentSpan) -> Vec { + let mut offset = 0_usize; + span + .paragraphs + .iter() + .enumerate() + .map(|(paragraph_ix, paragraph)| { + if paragraph_ix > 0 + && span + .text + .get(offset..) + .is_some_and(|text| text.starts_with('\n')) + { + offset += '\n'.len_utf8(); + } + let len = flowstate_document::paragraph_text_len(paragraph); + let end = offset.saturating_add(len).min(span.text.len()); + let text = span.text.get(offset..end).unwrap_or_default().to_string(); + offset = end; + text + }) + .collect() +} + +fn mark_replacement_span( + body: &loro::LoroText, + first_boundary_unicode: usize, + text_start_unicode: usize, + span: &flowstate_document::DocumentSpan, + paragraph_texts: &[String], +) -> loro::LoroResult<()> { + if let Some(first) = span.paragraphs.first() { + body.mark( + first_boundary_unicode..first_boundary_unicode + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(first.style), + )?; + } + mark_projected_paragraphs(body, text_start_unicode, &span.paragraphs, paragraph_texts) +} + +fn mark_projected_paragraphs( + body: &loro::LoroText, + text_start_unicode: usize, + paragraphs: &[flowstate_document::Paragraph], + paragraph_texts: &[String], +) -> loro::LoroResult<()> { + let mut paragraph_start = text_start_unicode; + for (paragraph_ix, (paragraph, paragraph_text)) in paragraphs.iter().zip(paragraph_texts).enumerate() { + if paragraph_ix > 0 { + let boundary = paragraph_start.saturating_sub(1); + body.mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(paragraph.style))?; + } + mark_paragraph_runs(body, paragraph_start, paragraph_text, ¶graph.runs)?; + paragraph_start += paragraph_text.chars().count() + 1; + } + Ok(()) +} + +fn mark_paragraph_runs( + body: &loro::LoroText, + paragraph_start_unicode: usize, + paragraph_text: &str, + runs: &[flowstate_document::TextRun], +) -> loro::LoroResult<()> { + let mut byte_offset = 0_usize; + for run in runs { + let end = byte_offset + .saturating_add(run.len) + .min(paragraph_text.len()); + let Some(run_text) = paragraph_text.get(byte_offset..end) else { + break; + }; + let run_len = run_text.chars().count(); + if run_len > 0 { + let run_start = paragraph_start_unicode + + paragraph_text + .get(..byte_offset) + .unwrap_or_default() + .chars() + .count(); + mark_run_styles(body, run_start..run_start + run_len, run.styles)?; + } + byte_offset = end; + } + Ok(()) +} + +fn inserted_newline_boundaries(start_unicode: usize, text: &str) -> Vec { + text + .chars() + .enumerate() + .filter_map(|(offset, ch)| (ch == '\n').then_some(start_unicode + offset)) + .collect() +} + +fn persist_body_paragraph_style_mark_repair(doc: &LoroDoc, package: Option<&mut DocumentPackage>, package_path: Option<&Path>) -> Result { + let from_frontier = doc.state_frontiers(); + let from_vv = doc.state_vv(); + let replica_registered = flowstate_document::register_replica(doc, None)?; + let paragraph_marks_repaired = repair_missing_paragraph_style_marks(doc)?; + if !replica_registered && !paragraph_marks_repaired { + return Ok(false); + } + let Some(package) = package else { + return Ok(paragraph_marks_repaired); + }; + package.sync_revisions_from_loro(doc)?; + let update = doc + .export(ExportMode::updates(&from_vv)) + .context("exporting paragraph style repair update")?; + if !update.is_empty() { + package.append_update_segment(&from_frontier, &from_vv, &doc.state_frontiers(), &doc.state_vv(), update)?; + package.compact_update_segments_if_needed(doc, DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD)?; + } + package.rebuild_search_units_from_loro(doc)?; + if let Some(path) = package_path { + package.write(path)?; + } + Ok(paragraph_marks_repaired) +} + +fn repair_missing_paragraph_style_marks(doc: &LoroDoc) -> Result { + let root = doc.get_map(ROOT); + let Some(flows) = child_map(&root, FLOWS_BY_ID) else { + return Ok(false); + }; + let mut repaired = false; + for flow_id in map_keys(&flows) { + let Some(flow) = child_map(&flows, &flow_id) else { + continue; + }; + if !matches!( + map_string_opt(&flow, FLOW_KIND_KEY).as_deref(), + Some("body" | "table_cell" | "caption" | "header" | "footer") + ) { + continue; + } + let Some(ValueOrContainer::Container(Container::Text(text))) = flow.get(FLOW_TEXT_KEY) else { + continue; + }; + for boundary in body_paragraph_boundaries_missing_style_mark(&text) { + text + .mark( + boundary..boundary + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(ParagraphStyle::Normal), + ) + .context("repairing missing paragraph style mark")?; + repaired = true; + } + } + if repaired { + doc.commit(); + } + Ok(repaired) +} + +fn body_paragraph_boundaries_missing_style_mark(body: &loro::LoroText) -> Vec { + let mut missing = Vec::new(); + let mut unicode_pos = 0_usize; + for item in body.to_delta() { + let loro::TextDelta::Insert { insert, attributes } = item else { + continue; + }; + let has_paragraph_style = paragraph_style_from_attrs(attributes.as_ref()).is_some(); + for ch in insert.chars() { + if ch == '\n' && !has_paragraph_style { + missing.push(unicode_pos); + } + unicode_pos += 1; + } + } + missing +} + +fn replacement_span_boundaries(first_boundary_unicode: usize, text_start_unicode: usize, paragraph_texts: &[String]) -> Vec { + if paragraph_texts.is_empty() { + return Vec::new(); + } + let mut boundaries = Vec::with_capacity(paragraph_texts.len()); + boundaries.push(first_boundary_unicode); + let mut paragraph_start = text_start_unicode; + for (paragraph_ix, paragraph_text) in paragraph_texts.iter().enumerate() { + if paragraph_ix > 0 { + boundaries.push(paragraph_start.saturating_sub(1)); + } + paragraph_start += paragraph_text.chars().count() + 1; + } + boundaries +} + +fn repair_paragraph_metadata_after_text_flow_edit( + doc: &LoroDoc, + body: &loro::LoroText, + live_boundaries: &[usize], + reason: &'static str, +) -> loro::LoroResult<()> { + for boundary in live_boundaries { + ensure_paragraph_metadata_at_boundary(doc, body, *boundary)?; + } + let pruned = prune_stale_paragraph_metadata(doc, body)?; + if pruned.changed() { + tracing::warn!( + reason, + stale_paragraphs = pruned.stale_paragraphs, + duplicate_paragraphs = pruned.duplicate_paragraphs, + stale_blocks = pruned.stale_blocks, + duplicate_blocks = pruned.duplicate_blocks, + "pruned stale Loro paragraph metadata after text-flow edit", + ); + } + Ok(()) +} + +fn ensure_paragraph_metadata_at_boundary(doc: &LoroDoc, body: &loro::LoroText, boundary: usize) -> loro::LoroResult<()> { + let body_snapshot = body.to_string(); + if !boundary_is_live(&body_snapshot, boundary) { + tracing::warn!( + boundary, + "cannot create paragraph metadata because boundary is not a live paragraph newline" + ); + return Ok(()); + } + + let root = doc.get_map(ROOT); + let paragraphs = root.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID)?; + let paragraph_id = + paragraph_metadata_key_at_boundary(doc, &body_snapshot, ¶graphs, boundary).unwrap_or_else(|| new_paragraph_metadata_id(boundary)); + let paragraph = paragraphs.ensure_mergeable_map(¶graph_id)?; + paragraph.insert("id", paragraph_id.as_str())?; + paragraph.insert("container_id", paragraph.id().to_string())?; + paragraph.insert("flow_id", ROOT_BODY_FLOW_ID)?; + if let Some(cursor) = body.get_cursor(boundary, Side::Left) { + paragraph.insert("start_cursor", cursor.encode())?; + } + if let Some(cursor) = body.get_cursor(boundary, Side::Left) { + paragraph.insert("boundary_cursor", cursor.encode())?; + } + let paragraph_attrs = paragraph.ensure_mergeable_map("attrs")?; + paragraph.insert("attrs_container_id", paragraph_attrs.id().to_string())?; + + let block_id = paragraph_block_key_at_boundary(doc, &body_snapshot, &blocks, boundary).unwrap_or_else(|| new_paragraph_block_id(boundary)); + let block = blocks.ensure_mergeable_map(&block_id)?; + block.insert("id", block_id.as_str())?; + block.insert("container_id", block.id().to_string())?; + block.insert("kind", "paragraph")?; + block.insert("flow_id", ROOT_BODY_FLOW_ID)?; + block.insert("paragraph_id", paragraph_id.as_str())?; + if let Some(cursor) = body.get_cursor(boundary, Side::Left) { + block.insert("anchor_cursor", cursor.encode())?; + } + let block_attrs = block.ensure_mergeable_map("attrs")?; + let nested_refs = block.ensure_mergeable_map("nested_refs")?; + block.insert("attrs_container_id", block_attrs.id().to_string())?; + block.insert("nested_refs_container_id", nested_refs.id().to_string())?; + Ok(()) +} + +fn paragraph_metadata_key_at_boundary(doc: &LoroDoc, body_snapshot: &str, paragraphs: &LoroMap, boundary: usize) -> Option { + let mut keys = metadata_keys_at_boundary(doc, body_snapshot, paragraphs, "boundary_cursor", boundary); + if boundary == 0 + && let Some(root_ix) = keys.iter().position(|key| key == ROOT_FIRST_PARAGRAPH_ID) + { + return Some(keys.swap_remove(root_ix)); + } + keys.into_iter().next() +} + +fn paragraph_block_key_at_boundary(doc: &LoroDoc, body_snapshot: &str, blocks: &LoroMap, boundary: usize) -> Option { + let mut keys = Vec::new(); + for key in map_keys(blocks) { + let Some(block) = child_map(blocks, &key) else { + continue; + }; + if map_string_opt(&block, "kind").as_deref() != Some("paragraph") { + continue; + } + if live_cursor_pos(doc, body_snapshot, &block, "anchor_cursor") == Some(boundary) { + keys.push(key); + } + } + if boundary == 0 + && let Some(main_ix) = keys.iter().position(|key| key == MAIN_BODY_BLOCK_ID) + { + return Some(keys.swap_remove(main_ix)); + } + keys.into_iter().next() +} + +fn metadata_keys_at_boundary(doc: &LoroDoc, body_snapshot: &str, maps: &LoroMap, cursor_key: &str, boundary: usize) -> Vec { + map_keys(maps) + .into_iter() + .filter(|key| { + child_map(maps, key) + .as_ref() + .and_then(|map| live_cursor_pos(doc, body_snapshot, map, cursor_key)) + == Some(boundary) + }) + .collect() +} + +#[derive(Default)] +struct ParagraphMetadataPrune { + stale_paragraphs: usize, + duplicate_paragraphs: usize, + stale_blocks: usize, + duplicate_blocks: usize, +} + +impl ParagraphMetadataPrune { + fn changed(&self) -> bool { + self.stale_paragraphs > 0 || self.duplicate_paragraphs > 0 || self.stale_blocks > 0 || self.duplicate_blocks > 0 + } +} + +fn prune_stale_paragraph_metadata(doc: &LoroDoc, body: &loro::LoroText) -> loro::LoroResult { + let body_snapshot = body.to_string(); + let root = doc.get_map(ROOT); + let paragraphs = root.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID)?; + let mut pruned = ParagraphMetadataPrune::default(); + + let mut block_boundary_by_paragraph = FxHashMap::::default(); + for key in map_keys(&blocks) { + let Some(block) = child_map(&blocks, &key) else { + continue; + }; + if map_string_opt(&block, "kind").as_deref() != Some("paragraph") { + continue; + } + let Some(paragraph_id) = map_string_opt(&block, "paragraph_id") else { + continue; + }; + let Some(boundary) = live_cursor_pos(doc, &body_snapshot, &block, "anchor_cursor") else { + continue; + }; + block_boundary_by_paragraph + .entry(paragraph_id) + .or_insert(boundary); + } + + let mut paragraph_by_boundary = BTreeMap::::new(); + let mut paragraphs_to_delete = Vec::new(); + for key in map_keys(¶graphs) { + let Some(paragraph) = child_map(¶graphs, &key) else { + paragraphs_to_delete.push(key); + pruned.stale_paragraphs += 1; + continue; + }; + let boundary = live_cursor_pos(doc, &body_snapshot, ¶graph, "boundary_cursor") + .or_else(|| live_cursor_pos(doc, &body_snapshot, ¶graph, "start_cursor")) + .or_else(|| { + let boundary = block_boundary_by_paragraph.get(&key).copied()?; + repair_paragraph_boundary_cursors(body, ¶graph, boundary).ok()?; + Some(boundary) + }); + let Some(boundary) = boundary else { + paragraphs_to_delete.push(key); + pruned.stale_paragraphs += 1; + continue; + }; + if let Some(existing) = paragraph_by_boundary.get(&boundary) { + if prefer_paragraph_metadata_key(boundary, existing, &key) { + paragraphs_to_delete.push(existing.clone()); + paragraph_by_boundary.insert(boundary, key); + } else { + paragraphs_to_delete.push(key); + } + pruned.duplicate_paragraphs += 1; + } else { + paragraph_by_boundary.insert(boundary, key); + } + } + for key in paragraphs_to_delete { + paragraphs.delete(&key)?; + } + + let mut block_by_boundary = BTreeMap::::new(); + let mut blocks_to_delete = Vec::new(); + for key in map_keys(&blocks) { + let Some(block) = child_map(&blocks, &key) else { + continue; + }; + if map_string_opt(&block, "kind").as_deref() != Some("paragraph") { + continue; + } + let Some(boundary) = live_cursor_pos(doc, &body_snapshot, &block, "anchor_cursor") else { + blocks_to_delete.push(key); + pruned.stale_blocks += 1; + continue; + }; + if let Some(existing) = block_by_boundary.get(&boundary) { + if prefer_paragraph_block_key(boundary, existing, &key) { + blocks_to_delete.push(existing.clone()); + block_by_boundary.insert(boundary, key); + } else { + blocks_to_delete.push(key); + } + pruned.duplicate_blocks += 1; + } else { + block_by_boundary.insert(boundary, key); + } + } + for key in blocks_to_delete { + blocks.delete(&key)?; + } + + Ok(pruned) +} + +fn repair_paragraph_boundary_cursors(body: &loro::LoroText, paragraph: &LoroMap, boundary: usize) -> loro::LoroResult<()> { + if let Some(cursor) = body.get_cursor(boundary, Side::Left) { + paragraph.insert("boundary_cursor", cursor.encode())?; + } + if let Some(cursor) = body.get_cursor(boundary, Side::Left) { + paragraph.insert("start_cursor", cursor.encode())?; + } + Ok(()) +} + +fn prefer_paragraph_metadata_key(boundary: usize, existing: &str, candidate: &str) -> bool { + boundary == 0 && candidate == ROOT_FIRST_PARAGRAPH_ID && existing != ROOT_FIRST_PARAGRAPH_ID +} + +fn prefer_paragraph_block_key(boundary: usize, existing: &str, candidate: &str) -> bool { + boundary == 0 && candidate == MAIN_BODY_BLOCK_ID && existing != MAIN_BODY_BLOCK_ID +} + +fn live_cursor_pos(doc: &LoroDoc, body_snapshot: &str, map: &LoroMap, cursor_key: &str) -> Option { + let cursor = Cursor::decode(&map_binary_opt(map, cursor_key)?).ok()?; + let pos = doc.get_cursor_pos(&cursor).ok()?.current.pos; + boundary_is_live(body_snapshot, pos).then_some(pos) +} + +fn live_object_cursor_pos(doc: &LoroDoc, body_snapshot: &str, map: &LoroMap, cursor_key: &str) -> Option { + let cursor = Cursor::decode(&map_binary_opt(map, cursor_key)?).ok()?; + let pos = doc.get_cursor_pos(&cursor).ok()?.current.pos; + (body_snapshot.chars().nth(pos) == Some(OBJECT_REPLACEMENT)).then_some(pos) +} + +fn boundary_is_live(body_snapshot: &str, boundary: usize) -> bool { + body_snapshot.chars().nth(boundary) == Some('\n') +} + +fn new_paragraph_metadata_id(boundary: usize) -> String { + if boundary == 0 { + ROOT_FIRST_PARAGRAPH_ID.to_string() + } else { + format!("paragraph.{}", Uuid::new_v4().as_u128()) + } +} + +fn new_paragraph_block_id(boundary: usize) -> String { + if boundary == 0 { + MAIN_BODY_BLOCK_ID.to_string() + } else { + format!("paragraph_block.{}", Uuid::new_v4().as_u128()) + } +} + +fn map_keys(map: &LoroMap) -> Vec { + let mut keys = map.keys().map(|key| key.to_string()).collect::>(); + keys.sort(); + keys +} + +fn child_map(parent: &LoroMap, key: &str) -> Option { + parent.get(key).and_then(|value| match value { + ValueOrContainer::Container(container) => container.into_map().ok(), + ValueOrContainer::Value(_) => None, + }) +} + +/// §28: centralized resolution of a stored raw Loro container id string. +/// +/// Parses the durable `*_container_id` string into a [`ContainerID`] and fetches +/// the live container directly from the document for efficient runtime access. +/// Returns `None` when the id is missing/unparseable or the container is +/// absent/detached/deleted, so callers can fall back to map-key traversal. +fn container_by_id(doc: &LoroDoc, container_id: &str) -> Option { + let container = doc.get_container(ContainerID::try_from(container_id).ok()?)?; + (container.is_attached() && !container.is_deleted()).then_some(container) +} + +fn container_map_by_id(doc: &LoroDoc, container_id: &str) -> Option { + container_by_id(doc, container_id)?.into_map().ok() +} + +fn container_text_by_id(doc: &LoroDoc, container_id: &str) -> Option { + container_by_id(doc, container_id)?.into_text().ok() +} + +/// §28: resolve a child container map, preferring the owner's stored raw +/// `*_container_id` and falling back to direct map-key traversal. +fn child_map_by_container_id(doc: &LoroDoc, owner: &LoroMap, container_id_key: &str, fallback_key: &str) -> Option { + if let Some(container_id) = map_string_opt(owner, container_id_key) + && let Some(map) = container_map_by_id(doc, &container_id) + { + return Some(map); + } + child_map(owner, fallback_key) +} + +/// §28: resolve a flow's canonical `LoroText`, preferring direct resolution via +/// the flow's stored `text_container_id` and falling back to key traversal when +/// the id is missing/unresolvable. +fn flow_text(doc: &LoroDoc, flow: &LoroMap) -> loro::LoroResult { + if let Some(container_id) = map_string_opt(flow, "text_container_id") + && let Some(text) = container_text_by_id(doc, &container_id) + { + return Ok(text); + } + flow.ensure_mergeable_text(FLOW_TEXT_KEY) +} + +fn child_movable_list(parent: &LoroMap, key: &str) -> Option { + parent.get(key).and_then(|value| match value { + ValueOrContainer::Container(Container::MovableList(list)) => Some(list), + _ => None, + }) +} + +fn movable_list_strings(list: &LoroMovableList) -> Vec { + (0..list.len()) + .filter_map(|ix| match list.get(ix) { + Some(ValueOrContainer::Value(LoroValue::String(value))) => Some(value.to_string()), + _ => None, + }) + .collect() +} + +fn map_string_opt(map: &LoroMap, key: &str) -> Option { + map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + }) +} + +fn map_binary_opt(map: &LoroMap, key: &str) -> Option> { + map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::Binary(value)) => Some(value.to_vec()), + _ => None, + }) +} + +fn attach_package_assets(document: &mut DocumentProjection, package: &DocumentPackage) { + flowstate_document::attach_package_assets(document, &package.assets); +} + +fn asset_record_changed(existing: &AssetRecord, next: &AssetRecord) -> bool { + existing.mime_type != next.mime_type + || existing.original_name != next.original_name + || existing.content_hash != next.content_hash + || existing.bytes.as_ref() != next.bytes.as_ref() +} + +fn install_undo_selection_callbacks(undo: &mut UndoManager, state: &Arc>) { + let push_state = Arc::clone(state); + undo.set_on_push(Some(Box::new(move |_, _, _| { + let mut meta = UndoItemMeta::new(); + if let Ok(state) = push_state.lock() + && let Some(selection) = &state.pending_selection + { + meta.set_value(LoroValue::Binary(selection.clone().into())); + } + meta + }))); + + let pop_state = Arc::clone(state); + undo.set_on_pop(Some(Box::new(move |_, _, meta| { + let LoroValue::Binary(bytes) = meta.value else { + return; + }; + match postcard::from_bytes::(bytes.as_ref()) { + Ok(selection) => { + if let Ok(mut state) = pop_state.lock() { + state.restored_selection = Some(selection); + } + }, + Err(error) => { + tracing::warn!(error = %error, "decoding Loro undo selection metadata failed"); + }, + } + }))); +} + +fn map_i64_opt(map: &LoroMap, key: &str) -> Option { + map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::I64(value)) => Some(value), + _ => None, + }) +} + +fn parse_blake3_hex(value: &str) -> Option<[u8; 32]> { + if value.len() != 64 { + return None; + } + let mut bytes = [0u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?; + } + Some(bytes) +} + +fn selection_direction(anchor: DocumentOffset, head: DocumentOffset) -> SelectionDirection { + match anchor.cmp(&head) { + std::cmp::Ordering::Less => SelectionDirection::Forward, + std::cmp::Ordering::Greater => SelectionDirection::Backward, + std::cmp::Ordering::Equal => SelectionDirection::None, + } +} + +fn cursor_for_boundary(text: &LoroText, unicode_pos: usize, affinity: SelectionAffinity) -> Option { + let len = text.len_unicode(); + let pos = unicode_pos.min(len); + match affinity { + SelectionAffinity::Before if pos > 0 => text.get_cursor(pos - 1, Side::Right), + SelectionAffinity::Before => text.get_cursor(0, Side::Left), + SelectionAffinity::After if pos < len => text.get_cursor(pos, Side::Left), + SelectionAffinity::After => text.get_cursor(len, Side::Right), + SelectionAffinity::Neutral => text.get_cursor(pos, Side::Middle), + } +} + +fn resolved_cursor_boundary_unicode(text: &LoroText, resolved: &loro::cursor::PosQueryResult) -> Option { + let event_len = text.convert_pos(text.len_utf8(), PosType::Bytes, PosType::Event)?; + let event_pos = resolved + .current + .pos + .saturating_add(usize::from(resolved.current.side == Side::Right)) + .min(event_len); + text.convert_pos(event_pos, PosType::Event, PosType::Unicode) +} + +fn gpui_gravity_from_presence(gravity: VisualGravity) -> gpui_flowtext::VisualGravity { + match gravity { + VisualGravity::Upstream => gpui_flowtext::VisualGravity::Upstream, + VisualGravity::Downstream => gpui_flowtext::VisualGravity::Downstream, + VisualGravity::Neutral => gpui_flowtext::VisualGravity::Neutral, + } +} + +fn undo_affinity(affinity: SelectionAffinity) -> UndoSelectionAffinity { + match affinity { + SelectionAffinity::Before => UndoSelectionAffinity::Before, + SelectionAffinity::After => UndoSelectionAffinity::After, + SelectionAffinity::Neutral => UndoSelectionAffinity::Neutral, + } +} + +/// §16: map the persisted undo-snapshot affinity back onto the editor's +/// `gpui_flowtext::SelectionAffinity` when rebuilding a restored selection. +fn gpui_affinity_from_undo(affinity: UndoSelectionAffinity) -> gpui_flowtext::SelectionAffinity { + match affinity { + UndoSelectionAffinity::Before => gpui_flowtext::SelectionAffinity::Before, + UndoSelectionAffinity::After => gpui_flowtext::SelectionAffinity::After, + UndoSelectionAffinity::Neutral => gpui_flowtext::SelectionAffinity::Neutral, + } +} + +pub(super) fn paragraph_style_from_attrs(attrs: Option<&FxHashMap>) -> Option { + let value = attrs?.get(MARK_PARAGRAPH_STYLE)?; + match value { + LoroValue::I64(0) => Some(ParagraphStyle::Normal), + LoroValue::I64(slot) if *slot > 0 => u8::try_from(*slot - 1).ok().map(ParagraphStyle::Custom), + _ => None, + } +} + +fn paragraph_style_value(style: ParagraphStyle) -> i64 { + match style { + ParagraphStyle::Normal => 0, + ParagraphStyle::Custom(slot) => i64::from(slot) + 1, + } +} + +fn mark_run_styles(text: &loro::LoroText, range: std::ops::Range, styles: RunStyles) -> loro::LoroResult<()> { + for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { + text.unmark(range.clone(), key)?; + } + if let RunSemanticStyle::Custom(slot) = styles.semantic { + text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; + } + if let Some(flowstate_document::HighlightStyle::Custom(slot)) = styles.highlight { + text.mark(range.clone(), MARK_HIGHLIGHT_STYLE, i64::from(slot))?; + } + if styles.direct_underline { + text.mark(range.clone(), MARK_DIRECT_UNDERLINE, true)?; + } + if styles.strikethrough { + text.mark(range, MARK_STRIKETHROUGH, true)?; + } + Ok(()) +} + +fn insert_image_block( + doc: &LoroDoc, + unicode_index: usize, + asset_id: u128, + alt_text: &str, + caption: Option<&str>, + sizing: InputImageSizing, + alignment: InputBlockAlignment, +) -> Result<()> { + let body = body_text(doc); + body.insert(unicode_index, &OBJECT_REPLACEMENT.to_string())?; + let block = ensure_block(doc, "image", ROOT_BODY_FLOW_ID, &body, unicode_index)?; + block.insert("asset_id", asset_id.to_string())?; + copy_asset_metadata_to_image_block(doc, &block, asset_id)?; + + let alt_flow_id = nested_flow_id("image_alt"); + block.insert("alt_text_flow_id", alt_flow_id.as_str())?; + let alt_flow = ensure_flow(doc, &alt_flow_id, "alt_text")?; + replace_text_incrementally(&alt_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?, alt_text)?; + + if let Some(caption) = caption { + let caption_flow_id = nested_flow_id("image_caption"); + block.insert("caption_flow_id", caption_flow_id.as_str())?; + let caption_flow = ensure_flow(doc, &caption_flow_id, "caption")?; + let caption_text = caption_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + replace_text(&caption_text, SENTINEL_NEWLINE)?; + caption_text.mark(0..1, MARK_PARAGRAPH_STYLE, 0_i64)?; + if !caption.is_empty() { + caption_text.insert(1, caption)?; + } + } + + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("alignment", alignment_name(alignment))?; + match sizing { + InputImageSizing::Intrinsic => attrs.insert("sizing", "intrinsic")?, + InputImageSizing::FitWidth => attrs.insert("sizing", "fit_width")?, + InputImageSizing::Fixed { width_px, height_px } => { + attrs.insert("sizing", "fixed")?; + attrs.insert("width_px", i64::from(width_px))?; + if let Some(height_px) = height_px { + attrs.insert("height_px", i64::from(height_px))?; + } + }, + }; + Ok(()) +} + +fn insert_image_block_with_id( + doc: &LoroDoc, + unicode_index: usize, + block_id: flowstate_document::BlockId, + image: &flowstate_document::InputImageBlock, +) -> Result<()> { + let body = body_text(doc); + body.insert(unicode_index, &OBJECT_REPLACEMENT.to_string())?; + let block_key = object_block_key("image", block_id); + let block = ensure_block_with_id(doc, &block_key, "image", ROOT_BODY_FLOW_ID, &body, unicode_index)?; + replace_image_block_from_input(doc, &block, image) +} + +fn insert_equation_block(doc: &LoroDoc, unicode_index: usize, source: &str, display: InputEquationDisplay) -> Result<()> { + let body = body_text(doc); + body.insert(unicode_index, &OBJECT_REPLACEMENT.to_string())?; + let block = ensure_block(doc, "equation", ROOT_BODY_FLOW_ID, &body, unicode_index)?; + let source_flow_id = nested_flow_id("equation_source"); + block.insert("source_flow_id", source_flow_id.as_str())?; + let source_flow = ensure_flow(doc, &source_flow_id, "equation_source")?; + replace_text(&source_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?, source)?; + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("syntax", "latex")?; + attrs.insert("display", equation_display_name(display))?; + Ok(()) +} + +fn insert_equation_block_with_id( + doc: &LoroDoc, + unicode_index: usize, + block_id: flowstate_document::BlockId, + equation: &flowstate_document::InputEquationBlock, +) -> Result<()> { + let body = body_text(doc); + body.insert(unicode_index, &OBJECT_REPLACEMENT.to_string())?; + let block_key = object_block_key("equation", block_id); + let block = ensure_block_with_id(doc, &block_key, "equation", ROOT_BODY_FLOW_ID, &body, unicode_index)?; + replace_equation_block_from_input(doc, &block, equation) +} + +fn insert_table_block( + doc: &LoroDoc, + unicode_index: usize, + rows: usize, + columns: usize, + column_widths: &[InputTableColumnWidth], + header_row: bool, +) -> Result<()> { + let body = body_text(doc); + body.insert(unicode_index, &OBJECT_REPLACEMENT.to_string())?; + let block = ensure_block(doc, "table", ROOT_BODY_FLOW_ID, &body, unicode_index)?; + let table = block.ensure_mergeable_map("table")?; + table.insert("header_row", header_row)?; + let row_order = table.ensure_mergeable_movable_list("row_order")?; + let column_order = table.ensure_mergeable_movable_list("column_order")?; + let rows_by_id = table.ensure_mergeable_map("rows_by_id")?; + let columns_by_id = table.ensure_mergeable_map("columns_by_id")?; + let cells_by_id = table.ensure_mergeable_map("cells_by_id")?; + table.insert("container_id", table.id().to_string())?; + table.insert("row_order_container_id", row_order.id().to_string())?; + table.insert("column_order_container_id", column_order.id().to_string())?; + table.insert("rows_container_id", rows_by_id.id().to_string())?; + table.insert("columns_container_id", columns_by_id.id().to_string())?; + table.insert("cells_container_id", cells_by_id.id().to_string())?; + let table_id = table_id(); + let mut column_ids = Vec::with_capacity(columns); + + for column_ix in 0..columns { + let column_id = format!("{table_id}.column.{column_ix}"); + column_order.push(column_id.as_str())?; + column_ids.push(column_id.clone()); + let column = columns_by_id.ensure_mergeable_map(&column_id)?; + column.insert("id", column_id.as_str())?; + column.insert("container_id", column.id().to_string())?; + let attrs = column.ensure_mergeable_map("attrs")?; + column.insert("attrs_container_id", attrs.id().to_string())?; + let width = column_widths + .get(column_ix) + .unwrap_or(&InputTableColumnWidth::Auto); + match *width { + InputTableColumnWidth::Auto => column.insert("width_kind", "auto")?, + InputTableColumnWidth::FixedPx(px) => { + column.insert("width_kind", "fixed_px")?; + column.insert("width_px", i64::from(px))?; + }, + InputTableColumnWidth::Fraction(fraction) => { + column.insert("width_kind", "fraction")?; + column.insert("fraction", i64::from(fraction))?; + }, + }; + } + + for row_ix in 0..rows { + let row_id = format!("{table_id}.row.{row_ix}"); + row_order.push(row_id.as_str())?; + let row = rows_by_id.ensure_mergeable_map(&row_id)?; + row.insert("id", row_id.as_str())?; + row.insert("container_id", row.id().to_string())?; + let attrs = row.ensure_mergeable_map("attrs")?; + row.insert("attrs_container_id", attrs.id().to_string())?; + for (column_ix, column_id) in column_ids.iter().enumerate() { + let cell_id = format!("{row_id}.cell.{column_ix}"); + let cell = cells_by_id.ensure_mergeable_map(&cell_id)?; + cell.insert("id", cell_id.as_str())?; + cell.insert("container_id", cell.id().to_string())?; + cell.insert("row_id", row_id.as_str())?; + cell.insert("column_id", column_id.as_str())?; + cell.insert("row_span", 1_i64)?; + cell.insert("column_span", 1_i64)?; + let attrs = cell.ensure_mergeable_map("attrs")?; + cell.insert("attrs_container_id", attrs.id().to_string())?; + let nested_table_ids = cell.ensure_mergeable_movable_list("nested_table_ids")?; + let nested_tables_by_id = cell.ensure_mergeable_map("nested_tables_by_id")?; + cell.insert("nested_table_order_container_id", nested_table_ids.id().to_string())?; + cell.insert("nested_tables_container_id", nested_tables_by_id.id().to_string())?; + let flow_id = format!("{cell_id}.flow"); + cell.insert("flow_id", flow_id.as_str())?; + let flow = ensure_flow(doc, &flow_id, "table_cell")?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + cell.insert("flow_container_id", flow.id().to_string())?; + cell.insert("text_container_id", text.id().to_string())?; + replace_text(&text, SENTINEL_NEWLINE)?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, 0_i64)?; + } + } + Ok(()) +} + +fn insert_table_block_with_id( + doc: &LoroDoc, + unicode_index: usize, + block_id: flowstate_document::BlockId, + table: &InputTableBlock, +) -> Result<()> { + let body = body_text(doc); + body.insert(unicode_index, &OBJECT_REPLACEMENT.to_string())?; + let block_key = object_block_key("table", block_id); + let block = ensure_block_with_id(doc, &block_key, "table", ROOT_BODY_FLOW_ID, &body, unicode_index)?; + replace_table_block_from_input(doc, &block, table) +} + +fn ensure_flow(doc: &LoroDoc, flow_id: &str, kind: &str) -> loro::LoroResult { + let root = doc.get_map(ROOT); + let flows = root.ensure_mergeable_map(FLOWS_BY_ID)?; + let flow = flows.ensure_mergeable_map(flow_id)?; + flow.insert(FLOW_ID_KEY, flow_id)?; + flow.insert(FLOW_KIND_KEY, kind)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let attrs = flow.ensure_mergeable_map(FLOW_ATTRS_KEY)?; + flow.insert("container_id", flow.id().to_string())?; + flow.insert("text_container_id", text.id().to_string())?; + flow.insert("attrs_container_id", attrs.id().to_string())?; + Ok(flow) +} + +fn ensure_block(doc: &LoroDoc, kind: &str, flow_id: &str, text: &loro::LoroText, pos: usize) -> loro::LoroResult { + let id = format!("{kind}.{}", Uuid::new_v4().as_u128()); + ensure_block_with_id(doc, &id, kind, flow_id, text, pos) +} + +fn ensure_block_with_id(doc: &LoroDoc, id: &str, kind: &str, flow_id: &str, text: &loro::LoroText, pos: usize) -> loro::LoroResult { + let root = doc.get_map(ROOT); + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID)?; + let block = blocks.ensure_mergeable_map(id)?; + block.insert("id", id)?; + block.insert("container_id", block.id().to_string())?; + block.insert("kind", kind)?; + block.insert("flow_id", flow_id)?; + if let Some(cursor) = text.get_cursor(pos, Side::Left) { + block.insert("anchor_cursor", cursor.encode())?; + } + let attrs = block.ensure_mergeable_map("attrs")?; + let nested_refs = block.ensure_mergeable_map("nested_refs")?; + block.insert("attrs_container_id", attrs.id().to_string())?; + block.insert("nested_refs_container_id", nested_refs.id().to_string())?; + Ok(block) +} + +fn object_block_key(kind: &str, block_id: flowstate_document::BlockId) -> String { + format!("{kind}.{}", block_id.0) +} + +fn replace_text(text: &loro::LoroText, value: &str) -> loro::LoroResult<()> { + let len = text.len_unicode(); + if len > 0 { + text.delete(0, len)?; + } + if !value.is_empty() { + text.insert(0, value)?; + } + Ok(()) +} + +fn nested_flow_id(kind: &str) -> String { + format!("{kind}.{}", Uuid::new_v4().as_u128()) +} + +fn table_id() -> String { + format!("table.{}", Uuid::new_v4().as_u128()) +} + +fn alignment_name(alignment: InputBlockAlignment) -> &'static str { + match alignment { + InputBlockAlignment::Left => "left", + InputBlockAlignment::Center => "center", + InputBlockAlignment::Right => "right", + } +} + +fn equation_display_name(display: InputEquationDisplay) -> &'static str { + match display { + InputEquationDisplay::Display => "display", + InputEquationDisplay::InlineLikeParagraph => "inline_like_paragraph", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use flowstate_document::{DocumentPackage, InputRun, ProjectionPatch, ProjectionTextDelta, loro_schema::body_text}; + + fn live_paragraph_metadata_boundaries(doc: &LoroDoc) -> Vec { + let body = body_text(doc); + let snapshot = body.to_string(); + let root = doc.get_map(ROOT); + let paragraphs = root + .ensure_mergeable_map(PARAGRAPHS_BY_ID) + .expect("paragraph registry"); + let mut boundaries = map_keys(¶graphs) + .into_iter() + .filter_map(|key| child_map(¶graphs, &key)) + .filter_map(|paragraph| live_cursor_pos(doc, &snapshot, ¶graph, "boundary_cursor")) + .collect::>(); + boundaries.sort_unstable(); + boundaries + } + + fn live_paragraph_block_boundaries(doc: &LoroDoc) -> Vec { + let body = body_text(doc); + let snapshot = body.to_string(); + let root = doc.get_map(ROOT); + let blocks = root + .ensure_mergeable_map(BLOCKS_BY_ID) + .expect("block registry"); + let mut boundaries = map_keys(&blocks) + .into_iter() + .filter_map(|key| child_map(&blocks, &key)) + .filter(|block| map_string_opt(block, "kind").as_deref() == Some("paragraph")) + .filter_map(|block| live_cursor_pos(doc, &snapshot, &block, "anchor_cursor")) + .collect::>(); + boundaries.sort_unstable(); + boundaries + } + + fn input_paragraph(text: &str) -> flowstate_document::InputParagraph { + flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![flowstate_document::InputRun { + text: text.to_string(), + styles: flowstate_document::RunStyles::default(), + }], + } + } + + fn input_table(rows: Vec>, column_widths: Vec, header_row: bool) -> InputTableBlock { + InputTableBlock { + rows: rows + .into_iter() + .map(|row| flowstate_document::InputTableRow { + cells: row.into_iter().map(input_table_cell).collect(), + }) + .collect(), + column_widths, + style: flowstate_document::InputTableStyle { header_row }, + } + } + + fn input_table_cell(text: &str) -> flowstate_document::InputTableCell { + flowstate_document::InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(input_paragraph(text))], + row_span: 1, + col_span: 1, + } + } + + fn projected_table_cell_text(table: &flowstate_document::TableBlock, row_ix: usize, cell_ix: usize) -> &str { + let flowstate_document::TableCellBlock::Paragraph(paragraph) = &table.rows[row_ix].cells[cell_ix].blocks[0] else { + panic!("expected paragraph table cell"); + }; + ¶graph.text + } + + fn local_update_bytes(events: &[RuntimeEvent]) -> Vec { + events + .iter() + .find_map(|event| match event { + RuntimeEvent::LocalUpdate { bytes, .. } => Some(bytes.clone()), + RuntimeEvent::RemoteUpdateApplied { .. } + | RuntimeEvent::RevisionOpened { .. } + | RuntimeEvent::RevisionForked { .. } + | RuntimeEvent::ProjectionUpdated { .. } + | RuntimeEvent::ProjectionPatched { .. } + | RuntimeEvent::SelectionRestored { .. } => None, + }) + .expect("local update bytes") + } + + #[test] + fn local_insert_exports_update_and_invalidates_projection() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + let events = runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?; + assert!(matches!(events.first(), Some(RuntimeEvent::LocalUpdate { bytes, .. }) if !bytes.is_empty())); + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::ProjectionPatched { .. })) + ); + assert_eq!(flowstate_document::paragraph_text(&runtime.projection_snapshot()?, 0), "hello"); + assert_eq!(body_text(runtime.doc()).to_string(), "\nhello"); + Ok(()) + } + + #[test] + fn semantic_insert_text_projects_inserted_run_styles() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + let styles = RunStyles { + semantic: RunSemanticStyle::Custom(2), + ..RunStyles::default() + }; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "styled".to_string(), + styles, + })?; + + let projection = runtime.projection_snapshot()?; + assert_eq!(flowstate_document::paragraph_text(&projection, 0), "styled"); + assert_eq!(projection.paragraphs[0].runs.len(), 1); + assert_eq!(projection.paragraphs[0].runs[0].styles, styles); + Ok(()) + } + + #[test] + fn editor_insert_text_preserves_paragraph_style_mark() -> Result<()> { + let source = flowstate_document::document_from_input( + flowstate_document::flowstate_document_theme(), + vec![InputParagraph { + style: ParagraphStyle::Custom(0), + runs: vec![InputRun { + text: "pocket".to_string(), + styles: RunStyles::default(), + }], + }], + ); + let doc = flowstate_document::document_to_loro(&source, "Styled")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::InsertText { + at: flowstate_document::DocumentOffset { paragraph: 0, byte: 3 }, + text: "x".to_string(), + styles: RunStyles::default(), + }, + )?; + + let updated = runtime.projection_snapshot()?; + assert_eq!(flowstate_document::paragraph_text(&updated, 0), "pocxket"); + assert_eq!(updated.paragraphs[0].style, ParagraphStyle::Custom(0)); + Ok(()) + } + + #[test] + fn split_paragraph_creates_live_paragraph_metadata_and_block_anchor() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 3, + inherited_style: flowstate_document::ParagraphStyle::Normal, + })?; + + assert_eq!(body_text(runtime.doc()).to_string(), "\nhe\nllo"); + assert_eq!(live_paragraph_metadata_boundaries(runtime.doc()), vec![0, 3]); + assert_eq!(live_paragraph_block_boundaries(runtime.doc()), vec![0, 3]); + let projection = runtime.projection_snapshot()?; + assert_eq!(projection.paragraphs.len(), 2); + assert_eq!(flowstate_document::paragraph_text(&projection, 0), "he"); + assert_eq!(flowstate_document::paragraph_text(&projection, 1), "llo"); + Ok(()) + } + + #[test] + fn runtime_repairs_missing_paragraph_style_marks_on_takeover() -> Result<()> { + let doc = new_loro_document("Malformed")?; + let body = body_text(&doc); + body.insert(1, "bad\nnext")?; + doc.commit(); + assert_eq!(body_paragraph_boundaries_missing_style_mark(&body), vec![4]); + + let runtime = CrdtRuntime::from_doc(doc, None, None)?; + + assert!(body_paragraph_boundaries_missing_style_mark(&body_text(runtime.doc())).is_empty()); + let projection = runtime.projection_snapshot()?; + assert_eq!(projection.paragraphs.len(), 2); + assert_eq!(flowstate_document::paragraph_text(&projection, 0), "bad"); + assert_eq!(flowstate_document::paragraph_text(&projection, 1), "next"); + assert_eq!(projection.paragraphs[1].style, ParagraphStyle::Normal); + Ok(()) + } + + #[test] + fn package_open_persists_missing_paragraph_style_mark_repair() -> Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("malformed.db8"); + let doc = new_loro_document("Malformed")?; + let body = body_text(&doc); + body.insert(1, "bad\nnext")?; + doc.commit(); + assert_eq!(body_paragraph_boundaries_missing_style_mark(&body), vec![4]); + DocumentPackage::from_loro_snapshot(&doc, "Malformed")?.write(&path)?; + + let _runtime = CrdtRuntime::open_package(&path)?; + let package = DocumentPackage::read(&path)?; + let loaded = package.load_loro_doc()?; + + assert_eq!(body_text(&loaded).to_string(), "\nbad\nnext"); + assert!(body_paragraph_boundaries_missing_style_mark(&body_text(&loaded)).is_empty()); + assert_eq!(package.loro_update_segments.len(), 1); + Ok(()) + } + + #[test] + fn remote_import_repairs_and_publishes_missing_paragraph_style_marks() -> Result<()> { + let base = new_loro_document("Malformed")?; + let source = base.fork(); + let from_vv = base.state_vv(); + body_text(&source).insert(1, "bad\nnext")?; + source.commit(); + let update = source.export(ExportMode::updates(&from_vv))?; + + let mut target = CrdtRuntime::from_doc(base, None, None)?; + let events = target.import_remote_update(&update)?; + + assert!(body_paragraph_boundaries_missing_style_mark(&body_text(target.doc())).is_empty()); + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + Ok(()) + } + + #[test] + fn join_paragraphs_deletes_boundary_and_prunes_stale_metadata() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 6, + inherited_style: flowstate_document::ParagraphStyle::Normal, + })?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 7, + text: "world".to_string(), + styles: RunStyles::default(), + })?; + assert_eq!(live_paragraph_metadata_boundaries(runtime.doc()), vec![0, 6]); + + let before_join = runtime.projection_snapshot()?; + let first = before_join.ids.paragraph_ids[0]; + let second = before_join.ids.paragraph_ids[1]; + let events = runtime.apply_editor_semantic_command(&before_join, &EditorSemanticCommand::JoinParagraphs { first, second })?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + assert_eq!(body_text(runtime.doc()).to_string(), "\nhelloworld"); + assert_eq!(live_paragraph_metadata_boundaries(runtime.doc()), vec![0]); + assert_eq!(live_paragraph_block_boundaries(runtime.doc()), vec![0]); + Ok(()) + } + + #[test] + fn insert_into_empty_paragraph_before_text_preserves_following_paragraph_identity() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "before".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 7, + inherited_style: flowstate_document::ParagraphStyle::Normal, + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 8, + inherited_style: flowstate_document::ParagraphStyle::Normal, + })?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 9, + text: "after".to_string(), + styles: RunStyles::default(), + })?; + + let before_insert = runtime.projection_snapshot()?; + assert_eq!(before_insert.paragraphs.len(), 3); + assert_eq!(flowstate_document::paragraph_text(&before_insert, 0), "before"); + assert_eq!(flowstate_document::paragraph_text(&before_insert, 1), ""); + assert_eq!(flowstate_document::paragraph_text(&before_insert, 2), "after"); + let following_id = before_insert.ids.paragraph_ids[2]; + { + let body = body_text(runtime.doc()); + let snapshot = body.to_string(); + let root = runtime.doc().get_map(ROOT); + let paragraphs = root.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let mut rewrote_following_boundary_cursor = false; + for key in map_keys(¶graphs) { + let Some(paragraph) = child_map(¶graphs, &key) else { + continue; + }; + if live_cursor_pos(runtime.doc(), &snapshot, ¶graph, "boundary_cursor") == Some(8) { + if let Some(cursor) = body.get_cursor(8, Side::Right) { + paragraph.insert("boundary_cursor", cursor.encode())?; + rewrote_following_boundary_cursor = true; + } + } + } + assert!(rewrote_following_boundary_cursor); + runtime.doc().commit(); + } + + let events = runtime.apply_editor_semantic_command( + &before_insert, + &EditorSemanticCommand::InsertText { + at: DocumentOffset { paragraph: 1, byte: 0 }, + text: "X".to_string(), + styles: RunStyles::default(), + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + assert_eq!(body_text(runtime.doc()).to_string(), "\nbefore\nX\nafter"); + assert_eq!(live_paragraph_metadata_boundaries(runtime.doc()), vec![0, 7, 9]); + assert_eq!(live_paragraph_block_boundaries(runtime.doc()), vec![0, 7, 9]); + let after_insert = runtime.projection_snapshot()?; + assert_eq!(after_insert.ids.paragraph_ids[2], following_id); + assert_eq!(flowstate_document::paragraph_text(&after_insert, 1), "X"); + assert_eq!(flowstate_document::paragraph_text(&after_insert, 2), "after"); + Ok(()) + } + + #[test] + fn runtime_persists_local_update_segments() -> Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("runtime.db8"); + let doc = flowstate_document::new_loro_document("Runtime")?; + DocumentPackage::from_loro_snapshot(&doc, "Runtime")?.write(&path)?; + let mut runtime = CrdtRuntime::open_package(&path)?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "persisted".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 10, + text: " twice".to_string(), + styles: RunStyles::default(), + })?; + let package = DocumentPackage::read(&path)?; + assert!(package.loro_update_segments.len() >= 3); + assert!(package.current_search_units().is_empty()); + let loaded = package.load_loro_doc()?; + assert_eq!(body_text(&loaded).to_string(), "\npersisted twice"); + Ok(()) + } + + #[test] + fn semantic_text_commands_mutate_loro_body_flow() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello world".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::DeleteRange { + unicode_index: 6, + unicode_len: 1, + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 6, + inherited_style: flowstate_document::ParagraphStyle::Custom(2), + })?; + runtime.command(SemanticCommand::SetRunStyles { + unicode_range: 1..6, + styles: flowstate_document::RunStyles { + semantic: flowstate_document::RunSemanticStyle::Custom(3), + direct_underline: true, + strikethrough: false, + highlight: Some(flowstate_document::HighlightStyle::Custom(4)), + }, + })?; + + assert_eq!(body_text(runtime.doc()).to_string(), "\nhello\nworld"); + let delta = body_text(runtime.doc()).to_delta(); + assert!(delta.iter().any(|item| matches!( + item, + loro::TextDelta::Insert { + attributes: Some(attributes), + .. + } if attributes.get(flowstate_document::MARK_RUN_SEMANTIC_STYLE).is_some() + ))); + assert!(delta.iter().any(|item| matches!( + item, + loro::TextDelta::Insert { + insert, + attributes: Some(attributes), + } if insert == "\n" && attributes.get(flowstate_document::MARK_PARAGRAPH_STYLE).is_some() + ))); + Ok(()) + } + + #[test] + fn editor_replace_paragraph_span_preserves_boundaries_and_marks() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![flowstate_document::InputBlock::Paragraph(flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![flowstate_document::InputRun { + text: "old".to_string(), + styles: flowstate_document::RunStyles::default(), + }], + })], + ); + let replacement_styles = flowstate_document::RunStyles { + semantic: flowstate_document::RunSemanticStyle::Custom(3), + direct_underline: true, + strikethrough: false, + highlight: Some(flowstate_document::HighlightStyle::Custom(4)), + }; + let replacement = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Custom(2), + runs: vec![flowstate_document::InputRun { + text: "Hello".to_string(), + styles: replacement_styles, + }], + }), + flowstate_document::InputBlock::Paragraph(flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![flowstate_document::InputRun { + text: "World".to_string(), + styles: flowstate_document::RunStyles::default(), + }], + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Span")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + + let events = runtime.apply_editor_semantic_command( + &source, + &EditorSemanticCommand::ReplaceParagraphSpan { + start: None, + before: flowstate_document::capture_document_span(&source, 0..1), + after: flowstate_document::capture_document_span(&replacement, 0..2), + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + assert_eq!(body_text(runtime.doc()).to_string(), "\nHello\nWorld"); + let projection = runtime.projection_snapshot()?; + assert_eq!(flowstate_document::paragraph_text(&projection, 0), "Hello"); + assert_eq!(flowstate_document::paragraph_text(&projection, 1), "World"); + assert_eq!(projection.paragraphs[0].style, flowstate_document::ParagraphStyle::Custom(2)); + assert_eq!(projection.paragraphs[0].runs[0].styles, replacement_styles); + assert_eq!(live_paragraph_metadata_boundaries(runtime.doc()), vec![0, 6]); + assert_eq!(live_paragraph_block_boundaries(runtime.doc()), vec![0, 6]); + Ok(()) + } + + #[test] + fn editor_replace_block_updates_image_metadata() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "old".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Replace Image")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + + let events = runtime.apply_editor_semantic_command( + &source, + &EditorSemanticCommand::ReplaceBlock { + block: Some(source.ids.block_ids[1]), + block_ix: 1, + after: flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(9), + alt_text: "new alt".to_string(), + caption: Some(input_paragraph("caption")), + sizing: flowstate_document::InputImageSizing::Fixed { + width_px: 640, + height_px: Some(480), + }, + alignment: flowstate_document::InputBlockAlignment::Right, + }), + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Image(image) = &projection.blocks[1] else { + panic!("expected image block after ReplaceBlock"); + }; + assert_eq!(image.asset_id, flowstate_document::AssetId(9)); + assert_eq!(image.alt_text.as_ref(), "new alt"); + assert!(image.caption.is_some()); + assert_eq!( + image.sizing, + flowstate_document::ImageSizing::Fixed { + width_px: 640, + height_px: Some(480), + } + ); + assert_eq!(image.alignment, flowstate_document::BlockAlignment::Right); + Ok(()) + } + + #[test] + fn editor_replace_block_prefers_projected_loro_id_over_stale_index() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "old".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Replace Image")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let image_block = projection.ids.block_ids[1]; + + let events = runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::ReplaceBlock { + block: Some(image_block), + block_ix: 99, + after: flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(9), + alt_text: "new alt".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::FitWidth, + alignment: flowstate_document::InputBlockAlignment::Right, + }), + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Image(image) = &projection.blocks[1] else { + panic!("expected image block after ReplaceBlock"); + }; + assert_eq!(image.asset_id, flowstate_document::AssetId(9)); + assert_eq!(image.alt_text.as_ref(), "new alt"); + assert_eq!(image.alignment, flowstate_document::BlockAlignment::Right); + Ok(()) + } + + #[test] + fn editor_replace_image_alt_text_updates_alt_flow() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "old".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Image Alt")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let image_block_id = projection.ids.block_ids[1]; + let body = body_text(runtime.doc()); + let (_, block, _) = object_loro_block_by_projected_id(runtime.doc(), &body, image_block_id).expect("image block"); + let before_flow = map_string_opt(&block, "alt_text_flow_id").expect("alt flow id"); + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::ReplaceImageAltText { + image: image_block_id, + text: "new alt".to_string(), + }, + )?; + + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Image(image) = &projection.blocks[1] else { + panic!("expected image block after alt text edit"); + }; + assert_eq!(image.alt_text.as_ref(), "new alt"); + let body = body_text(runtime.doc()); + let (_, block, _) = object_loro_block_by_projected_id(runtime.doc(), &body, image_block_id).expect("image block"); + assert_eq!(map_string_opt(&block, "alt_text_flow_id").as_deref(), Some(before_flow.as_str())); + Ok(()) + } + + #[test] + fn editor_set_image_layout_updates_image_attrs() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "alt".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Image Layout")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let image_block_id = projection.ids.block_ids[1]; + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::SetImageLayout { + image: image_block_id, + sizing: flowstate_document::InputImageSizing::Fixed { + width_px: 444, + height_px: None, + }, + alignment: flowstate_document::InputBlockAlignment::Center, + }, + )?; + + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Image(image) = &projection.blocks[1] else { + panic!("expected image block after layout edit"); + }; + assert_eq!(image.asset_id, flowstate_document::AssetId(1)); + assert_eq!(image.alt_text.as_ref(), "alt"); + assert_eq!(image.alignment, flowstate_document::BlockAlignment::Center); + assert_eq!( + image.sizing, + flowstate_document::ImageSizing::Fixed { + width_px: 444, + height_px: None, + } + ); + Ok(()) + } + + #[test] + fn editor_insert_block_creates_loro_object_from_projection_payload() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![flowstate_document::InputBlock::Paragraph(input_paragraph("body"))], + ); + let target = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(7), + alt_text: "inserted".to_string(), + caption: Some(input_paragraph("caption")), + sizing: flowstate_document::InputImageSizing::FitWidth, + alignment: flowstate_document::InputBlockAlignment::Center, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Insert Image")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let image_block = target.ids.block_ids[1]; + + let events = runtime.apply_editor_semantic_command( + &target, + &EditorSemanticCommand::InsertBlock { + block: image_block, + block_ix: 1, + after: flowstate_document::input_block_from_block(&target.blocks[1]), + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + assert!( + body_text(runtime.doc()) + .to_string() + .contains(OBJECT_REPLACEMENT) + ); + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Image(image) = &projection.blocks[1] else { + panic!("expected inserted image block"); + }; + assert_eq!(image.asset_id, flowstate_document::AssetId(7)); + assert_eq!(image.alt_text.as_ref(), "inserted"); + assert!(image.caption.is_some()); + assert_eq!(image.alignment, flowstate_document::BlockAlignment::Center); + assert_eq!(projection.ids.block_ids[1], image_block); + Ok(()) + } + + #[test] + fn editor_delete_block_removes_loro_object_by_projected_id() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "old".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Delete Image")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let image_block = projection.ids.block_ids[1]; + + let events = runtime.apply_editor_semantic_command(&projection, &EditorSemanticCommand::DeleteBlock { block: image_block })?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + assert!( + !body_text(runtime.doc()) + .to_string() + .contains(OBJECT_REPLACEMENT) + ); + let projection = runtime.projection_snapshot()?; + assert_eq!(projection.blocks.len(), 1); + assert!(matches!(&projection.blocks[0], flowstate_document::Block::Paragraph(_))); + Ok(()) + } + + #[test] + fn editor_delete_object_and_replace_paragraph_span_apply_together() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("alpha")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "alt".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + flowstate_document::InputBlock::Paragraph(input_paragraph("omega")), + ], + ); + let target = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![flowstate_document::InputBlock::Paragraph(input_paragraph("alega"))], + ); + let doc = flowstate_document::document_to_loro(&source, "Mixed Delete")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let image_block_id = projection.ids.block_ids[1]; + let before = flowstate_document::capture_document_span(&source, 0..2); + let after = flowstate_document::capture_document_span(&target, 0..1); + + runtime.apply_editor_semantic_command(&projection, &EditorSemanticCommand::DeleteBlock { block: image_block_id })?; + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::ReplaceParagraphSpan { + start: Some(flowstate_document::DocumentOffset { paragraph: 0, byte: 0 }), + before, + after, + }, + )?; + + let projection = runtime.projection_snapshot()?; + assert_eq!(projection.blocks.len(), 1); + assert_eq!(flowstate_document::paragraph_text(&projection, 0), "alega"); + assert!( + !body_text(runtime.doc()) + .to_string() + .contains(OBJECT_REPLACEMENT) + ); + Ok(()) + } + + #[test] + fn editor_move_block_reorders_loro_object_placeholder_by_projected_id() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(1), + alt_text: "first".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + flowstate_document::InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(2), + alt_text: "second".to_string(), + caption: None, + sizing: flowstate_document::InputImageSizing::Intrinsic, + alignment: flowstate_document::InputBlockAlignment::Left, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Move Image")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let second_image = projection.ids.block_ids[2]; + + let events = runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::MoveBlock { + block: second_image, + new_block_ix: 1, + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Image(image) = &projection.blocks[1] else { + panic!("expected moved image at block 1"); + }; + assert_eq!(image.alt_text.as_ref(), "second"); + assert_eq!(projection.ids.block_ids[1], second_image); + let flowstate_document::Block::Image(image) = &projection.blocks[2] else { + panic!("expected first image at block 2"); + }; + assert_eq!(image.alt_text.as_ref(), "first"); + Ok(()) + } + + #[test] + fn editor_replace_block_updates_equation_source() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Equation(flowstate_document::InputEquationBlock { + source: "x".to_string(), + syntax: flowstate_document::InputEquationSyntax::Latex, + display: flowstate_document::InputEquationDisplay::Display, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Replace Equation")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + + runtime.apply_editor_semantic_command( + &source, + &EditorSemanticCommand::ReplaceBlock { + block: Some(source.ids.block_ids[1]), + block_ix: 1, + after: flowstate_document::InputBlock::Equation(flowstate_document::InputEquationBlock { + source: "x+1".to_string(), + syntax: flowstate_document::InputEquationSyntax::Latex, + display: flowstate_document::InputEquationDisplay::InlineLikeParagraph, + }), + }, + )?; + + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Equation(equation) = &projection.blocks[1] else { + panic!("expected equation block after ReplaceBlock"); + }; + assert_eq!(equation.source.as_ref(), "x+1"); + assert_eq!(equation.display, flowstate_document::EquationDisplay::InlineLikeParagraph); + Ok(()) + } + + #[test] + fn editor_replace_equation_source_range_edits_source_flow() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Equation(flowstate_document::InputEquationBlock { + source: "x+y".to_string(), + syntax: flowstate_document::InputEquationSyntax::Latex, + display: flowstate_document::InputEquationDisplay::Display, + }), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Edit Equation Source")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let equation_block_id = projection.ids.block_ids[1]; + let body = body_text(runtime.doc()); + let (_, block, _) = object_loro_block_by_projected_id(runtime.doc(), &body, equation_block_id).expect("equation block"); + let before_flow = map_string_opt(&block, "source_flow_id").expect("source flow id"); + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::ReplaceEquationSourceRange { + equation: equation_block_id, + range: 1..2, + text: "*".to_string(), + }, + )?; + + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Equation(equation) = &projection.blocks[1] else { + panic!("expected equation block after source range edit"); + }; + assert_eq!(equation.source.as_ref(), "x*y"); + let body = body_text(runtime.doc()); + let (_, block, _) = object_loro_block_by_projected_id(runtime.doc(), &body, equation_block_id).expect("equation block"); + assert_eq!(map_string_opt(&block, "source_flow_id").as_deref(), Some(before_flow.as_str())); + Ok(()) + } + + #[test] + fn editor_replace_block_rebuilds_table_structure() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Table(input_table( + vec![vec!["old"]], + vec![flowstate_document::InputTableColumnWidth::Auto], + false, + )), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Replace Table")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + + runtime.apply_editor_semantic_command( + &source, + &EditorSemanticCommand::ReplaceBlock { + block: Some(source.ids.block_ids[1]), + block_ix: 1, + after: flowstate_document::InputBlock::Table(input_table( + vec![vec!["a", "b"], vec!["c", "d"]], + vec![ + flowstate_document::InputTableColumnWidth::FixedPx(90), + flowstate_document::InputTableColumnWidth::Fraction(1), + ], + true, + )), + }, + )?; + + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table) = &projection.blocks[1] else { + panic!("expected table block after ReplaceBlock"); + }; + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].cells.len(), 2); + assert!(table.style.header_row); + assert!(matches!( + table.column_widths.as_slice(), + [ + flowstate_document::TableColumnWidth::FixedPx(90), + flowstate_document::TableColumnWidth::Fraction(1) + ] + )); + let flowstate_document::TableCellBlock::Paragraph(cell) = &table.rows[1].cells[0].blocks[0] else { + panic!("expected paragraph cell after ReplaceBlock"); + }; + assert_eq!(cell.text, "c"); + Ok(()) + } + + #[test] + fn editor_set_table_column_width_preserves_table_identity() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Table(input_table( + vec![vec!["a", "b"], vec!["c", "d"]], + vec![ + flowstate_document::InputTableColumnWidth::Auto, + flowstate_document::InputTableColumnWidth::Fraction(1), + ], + false, + )), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Resize Table")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let table_block_id = projection.ids.block_ids[1]; + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + let before_rows = movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")); + let before_columns = movable_list_strings(&child_movable_list(&table, "column_order").expect("column order")); + + let events = runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::SetTableColumnWidth { + table: table_block_id, + column_ix: 1, + width: flowstate_document::InputTableColumnWidth::FixedPx(222), + }, + )?; + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")), + before_rows + ); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "column_order").expect("column order")), + before_columns + ); + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table) = &projection.blocks[1] else { + panic!("expected table block after column width command"); + }; + assert!(matches!( + table.column_widths.as_slice(), + [ + flowstate_document::TableColumnWidth::Auto, + flowstate_document::TableColumnWidth::FixedPx(222) + ] + )); + Ok(()) + } + + #[test] + fn editor_table_structure_commands_mutate_loro_table_incrementally() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Table(input_table( + vec![vec!["a", "b"], vec!["c", "d"]], + vec![ + flowstate_document::InputTableColumnWidth::Auto, + flowstate_document::InputTableColumnWidth::Fraction(1), + ], + false, + )), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Structure Table")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let mut projection = runtime.projection_snapshot()?; + let table_block_id = projection.ids.block_ids[1]; + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + let initial_rows = movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")); + let initial_columns = movable_list_strings(&child_movable_list(&table, "column_order").expect("column order")); + + let inserted_row = input_table(vec![vec!["new-a", "new-b"]], Vec::new(), false) + .rows + .into_iter() + .next() + .expect("row"); + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::InsertTableRow { + table: table_block_id, + row_ix: 1, + row: inserted_row, + }, + )?; + projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table_projection) = &projection.blocks[1] else { + panic!("expected table block after row insert"); + }; + assert_eq!(table_projection.rows.len(), 3); + assert_eq!(projected_table_cell_text(table_projection, 1, 0), "new-a"); + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "column_order").expect("column order")), + initial_columns + ); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")).len(), + initial_rows.len() + 1 + ); + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::InsertTableColumn { + table: table_block_id, + column_ix: 1, + width: flowstate_document::InputTableColumnWidth::FixedPx(88), + cells: vec![input_table_cell("x"), input_table_cell("y"), input_table_cell("z")], + }, + )?; + projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table_projection) = &projection.blocks[1] else { + panic!("expected table block after column insert"); + }; + assert_eq!(table_projection.column_widths.len(), 3); + assert!(matches!( + table_projection.column_widths.as_slice(), + [ + flowstate_document::TableColumnWidth::Auto, + flowstate_document::TableColumnWidth::FixedPx(88), + flowstate_document::TableColumnWidth::Fraction(1) + ] + )); + assert_eq!(projected_table_cell_text(table_projection, 0, 1), "x"); + assert_eq!(projected_table_cell_text(table_projection, 1, 1), "y"); + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")).len(), + initial_rows.len() + 1 + ); + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::DeleteTableRow { + table: table_block_id, + row_ix: 1, + }, + )?; + projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table_projection) = &projection.blocks[1] else { + panic!("expected table block after row delete"); + }; + assert_eq!(table_projection.rows.len(), 2); + assert_eq!(projected_table_cell_text(table_projection, 1, 0), "c"); + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::DeleteTableColumn { + table: table_block_id, + column_ix: 1, + }, + )?; + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table_projection) = &projection.blocks[1] else { + panic!("expected table block after column delete"); + }; + assert_eq!(table_projection.column_widths.len(), 2); + assert_eq!(projected_table_cell_text(table_projection, 0, 1), "b"); + assert_eq!(projected_table_cell_text(table_projection, 1, 1), "d"); + Ok(()) + } + + #[test] + fn editor_replace_table_cell_preserves_table_structure() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::flowstate_document_theme(), + vec![ + flowstate_document::InputBlock::Paragraph(input_paragraph("body")), + flowstate_document::InputBlock::Table(input_table( + vec![vec!["a", "b"], vec!["c", "d"]], + vec![ + flowstate_document::InputTableColumnWidth::Auto, + flowstate_document::InputTableColumnWidth::Fraction(1), + ], + false, + )), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Replace Cell")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + let projection = runtime.projection_snapshot()?; + let table_block_id = projection.ids.block_ids[1]; + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + let before_rows = movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")); + let before_columns = movable_list_strings(&child_movable_list(&table, "column_order").expect("column order")); + + runtime.apply_editor_semantic_command( + &projection, + &EditorSemanticCommand::ReplaceTableCell { + table: table_block_id, + row_ix: 1, + cell_ix: 0, + cell: input_table_cell("changed"), + }, + )?; + + let table = projection_table_map_by_block_id(runtime.doc(), table_block_id).expect("table map"); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "row_order").expect("row order")), + before_rows + ); + assert_eq!( + movable_list_strings(&child_movable_list(&table, "column_order").expect("column order")), + before_columns + ); + let projection = runtime.projection_snapshot()?; + let flowstate_document::Block::Table(table_projection) = &projection.blocks[1] else { + panic!("expected table block after cell replace"); + }; + assert_eq!(projected_table_cell_text(table_projection, 1, 0), "changed"); + assert_eq!(projected_table_cell_text(table_projection, 1, 1), "d"); + Ok(()) + } + + #[test] + fn undo_manager_restores_selection_metadata() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + let selection = UndoSelectionSnapshot { + anchor_cursor: vec![1, 2, 3], + head_cursor: vec![4, 5, 6], + anchor_affinity: UndoSelectionAffinity::Before, + head_affinity: UndoSelectionAffinity::After, + direction: UndoSelectionDirection::Forward, + }; + + runtime.set_pending_undo_selection(Some(selection.clone()))?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "abc".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::Undo)?; + + assert_eq!(runtime.take_restored_undo_selection(), Some(selection.clone())); + runtime.command(SemanticCommand::Redo)?; + assert_eq!(runtime.take_restored_undo_selection(), Some(selection)); + Ok(()) + } + + #[test] + fn semantic_object_commands_project_structured_blocks() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertImage { + unicode_index: 1, + asset_id: 7, + alt_text: "alt".to_string(), + caption: Some("caption".to_string()), + sizing: flowstate_document::InputImageSizing::Fixed { + width_px: 320, + height_px: Some(180), + }, + alignment: flowstate_document::InputBlockAlignment::Center, + })?; + runtime.command(SemanticCommand::InsertEquation { + unicode_index: 2, + source: "x^2".to_string(), + display: flowstate_document::InputEquationDisplay::InlineLikeParagraph, + })?; + runtime.command(SemanticCommand::InsertTable { + unicode_index: 3, + rows: 2, + columns: 2, + column_widths: vec![ + flowstate_document::InputTableColumnWidth::FixedPx(120), + flowstate_document::InputTableColumnWidth::Fraction(1), + ], + header_row: true, + })?; + + let projection = runtime.projection_snapshot()?; + assert!(matches!( + &projection.blocks[0], + flowstate_document::Block::Image(image) + if image.asset_id == flowstate_document::AssetId(7) + && image.alt_text.as_ref() == "alt" + && image.caption.is_some() + )); + assert!(matches!( + &projection.blocks[1], + flowstate_document::Block::Equation(equation) + if equation.source.as_ref() == "x^2" + && equation.display == flowstate_document::EquationDisplay::InlineLikeParagraph + )); + assert!(matches!( + &projection.blocks[2], + flowstate_document::Block::Table(table) + if table.rows.len() == 2 + && table.rows[0].cells.len() == 2 + && table.style.header_row + && matches!(table.column_widths.as_slice(), [ + flowstate_document::TableColumnWidth::FixedPx(120), + flowstate_document::TableColumnWidth::Fraction(1) + ]) + )); + Ok(()) + } + + #[test] + fn runtime_opens_and_forks_named_revisions() -> Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("revisions.db8"); + let doc = flowstate_document::new_loro_document("Runtime")?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Runtime")?; + let blank_revision = package.create_named_revision(&doc, "Blank", "Blank document", None, None)?; + body_text(&doc).insert(1, "latest")?; + doc.commit(); + package.compact_to_named_snapshot(&doc, "Latest", "Latest document", None, None)?; + package.write(&path)?; + + let mut runtime = CrdtRuntime::open_package(&path)?; + let opened = runtime.command(SemanticCommand::OpenRevision { revision_id: blank_revision })?; + assert!(matches!( + opened.as_slice(), + [RuntimeEvent::RevisionOpened { document, .. }] if document.paragraphs.first().is_some_and(|paragraph| paragraph.byte_range.is_empty()) + )); + + let forked = runtime.command(SemanticCommand::ForkRevision { revision_id: blank_revision })?; + let [RuntimeEvent::RevisionForked { document, package, .. }] = forked.as_slice() else { + panic!("expected fork event"); + }; + assert_eq!( + document + .paragraphs + .first() + .map(|paragraph| paragraph.byte_range.clone()), + Some(0..0) + ); + assert!(!package.loro_snapshots.is_empty()); + Ok(()) + } + + #[test] + fn remote_text_insert_emits_incremental_paragraph_patch() -> Result<()> { + let base = flowstate_document::new_loro_document("Shared")?; + let mut source = CrdtRuntime::from_doc(base.fork(), None, None)?; + let mut target = CrdtRuntime::from_doc(base, None, None)?; + let setup = source.export_updates_for(&target.doc().oplog_vv())?; + target.import_remote_update(&setup)?; + let update = local_update_bytes(&source.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?); + + let events = target.import_remote_update(&update)?; + let patches = events + .iter() + .find_map(|event| match event { + RuntimeEvent::ProjectionPatched { patches, .. } => Some(patches), + RuntimeEvent::LocalUpdate { .. } + | RuntimeEvent::RemoteUpdateApplied { .. } + | RuntimeEvent::RevisionOpened { .. } + | RuntimeEvent::RevisionForked { .. } + | RuntimeEvent::ProjectionUpdated { .. } + | RuntimeEvent::SelectionRestored { .. } => None, + }) + .expect("remote import should emit a projection patch"); + + let [ProjectionPatch::ParagraphText { row, new, delta_utf8 }] = patches.as_slice() else { + panic!("expected one paragraph text patch"); + }; + assert_eq!(*row, 0); + assert_eq!( + new + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + "hello" + ); + assert_eq!(delta_utf8, &[ProjectionTextDelta::Insert("hello".len())]); + assert!(events.iter().all(|event| !matches!( + event, + RuntimeEvent::ProjectionUpdated { + invalidation: ProjectionInvalidation { + fallback_reason: Some("remote_update_projection_fallback"), + .. + }, + .. + } + ))); + Ok(()) + } + + #[test] + fn remote_text_insert_in_object_document_still_emits_incremental_patch() -> Result<()> { + let source_projection = flowstate_document::document_from_input_blocks( + flowstate_document::DocumentTheme::default(), + vec![ + InputBlock::Paragraph(input_paragraph("alpha")), + InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(7), + alt_text: "figure".to_string(), + caption: None, + sizing: InputImageSizing::Intrinsic, + alignment: InputBlockAlignment::Center, + }), + InputBlock::Paragraph(input_paragraph("omega")), + ], + ); + let base = flowstate_document::document_to_loro(&source_projection, "Mixed")?; + let mut source = CrdtRuntime::from_doc(base.fork(), None, None)?; + let mut target = CrdtRuntime::from_doc(base, None, None)?; + let setup = source.export_updates_for(&target.doc().oplog_vv())?; + target.import_remote_update(&setup)?; + let unicode_index = body_text(source.doc()).len_unicode(); + let update = local_update_bytes(&source.command(SemanticCommand::InsertText { + unicode_index, + text: "!".to_string(), + styles: RunStyles::default(), + })?); + + let events = target.import_remote_update(&update)?; + let patches = events + .iter() + .find_map(|event| match event { + RuntimeEvent::ProjectionPatched { patches, .. } => Some(patches), + RuntimeEvent::LocalUpdate { .. } + | RuntimeEvent::RemoteUpdateApplied { .. } + | RuntimeEvent::RevisionOpened { .. } + | RuntimeEvent::RevisionForked { .. } + | RuntimeEvent::ProjectionUpdated { .. } + | RuntimeEvent::SelectionRestored { .. } => None, + }) + .expect("remote import should emit a projection patch"); + + let [ProjectionPatch::ParagraphText { row, new, delta_utf8 }] = patches.as_slice() else { + panic!("expected one paragraph text patch"); + }; + assert_eq!(*row, 2); + assert_eq!( + new + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + "omega!" + ); + assert_eq!( + delta_utf8, + &[ProjectionTextDelta::Retain("omega".len()), ProjectionTextDelta::Insert("!".len())] + ); + assert!( + events + .iter() + .all(|event| !matches!(event, RuntimeEvent::ProjectionUpdated { .. })) + ); + Ok(()) + } + + #[test] + fn local_text_insert_can_apply_without_projection_snapshot() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + let events = runtime + .try_apply_editor_semantic_command_without_projection(&EditorSemanticCommand::InsertText { + at: flowstate_document::DocumentOffset { paragraph: 0, byte: 0 }, + text: "hello".to_string(), + styles: RunStyles::default(), + })? + .expect("text insert should use body fast path"); + + assert!( + events + .iter() + .any(|event| matches!(event, RuntimeEvent::LocalUpdate { bytes, .. } if !bytes.is_empty())) + ); + assert!( + events + .iter() + .all(|event| !matches!(event, RuntimeEvent::ProjectionUpdated { .. })) + ); + assert_eq!(body_text(runtime.doc()).to_string(), "\nhello"); + Ok(()) + } + + #[test] + fn imported_runtime_startup_projection_accepts_the_first_editor_command() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::DocumentTheme::default(), + vec![InputBlock::Paragraph(input_paragraph("ready"))], + ); + let imported = flowstate_document::import_document_projection(source, "Imported startup")?; + let mut runtime = CrdtRuntime::from_imported_document(imported)?; + let startup = runtime.projection_snapshot()?; + + runtime.apply_editor_commands( + &startup.frontier, + &[EditorSemanticCommand::InsertText { + at: DocumentOffset { paragraph: 0, byte: 0 }, + text: "x".to_string(), + styles: RunStyles::default(), + }], + None, + )?; + + assert_eq!(flowstate_document::paragraph_text(&runtime.projection_snapshot()?, 0), "xready"); + Ok(()) + } + + #[test] + fn editor_commands_accept_projection_frontier_after_metadata_only_commit() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Metadata frontier")?; + let base_frontier = runtime.projection_snapshot()?.frontier; + runtime.set_author_identity(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef, Some("Author".to_string()))?; + assert_ne!(runtime.doc.state_frontiers().encode(), base_frontier); + assert_eq!(runtime.projection_snapshot()?.frontier, base_frontier); + + runtime.apply_editor_commands( + &base_frontier, + &[EditorSemanticCommand::InsertText { + at: DocumentOffset { paragraph: 0, byte: 0 }, + text: "x".to_string(), + styles: RunStyles::default(), + }], + None, + )?; + + assert_eq!(flowstate_document::paragraph_text(&runtime.projection_snapshot()?, 0), "x"); + Ok(()) + } + + #[test] + fn editor_commands_reject_a_stale_projection_frontier() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Stale frontier")?; + let base_frontier = runtime.projection_snapshot()?.frontier; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "remote".to_string(), + styles: RunStyles::default(), + })?; + + let error = runtime + .apply_editor_commands( + &base_frontier, + &[EditorSemanticCommand::InsertText { + at: DocumentOffset { paragraph: 0, byte: 0 }, + text: "local".to_string(), + styles: RunStyles::default(), + }], + None, + ) + .expect_err("stale editor commands must be rejected"); + + assert!(error.downcast_ref::().is_some()); + assert_eq!(body_text(runtime.doc()).to_string(), "\nremote"); + Ok(()) + } + + #[test] + fn editor_insert_after_object_uses_canonical_body_index() -> Result<()> { + let source = flowstate_document::document_from_input_blocks( + flowstate_document::DocumentTheme::default(), + vec![ + InputBlock::Paragraph(input_paragraph("before")), + InputBlock::Image(flowstate_document::InputImageBlock { + asset_id: flowstate_document::AssetId(9), + alt_text: "figure".to_string(), + caption: None, + sizing: InputImageSizing::Intrinsic, + alignment: InputBlockAlignment::Center, + }), + InputBlock::Paragraph(input_paragraph("after")), + ], + ); + let doc = flowstate_document::document_to_loro(&source, "Mixed editor offsets")?; + let mut runtime = CrdtRuntime::from_doc(doc, None, None)?; + + runtime.apply_editor_commands( + &[], + &[EditorSemanticCommand::InsertText { + at: DocumentOffset { paragraph: 1, byte: 2 }, + text: "!".to_string(), + styles: RunStyles::default(), + }], + None, + )?; + + let projection = runtime.projection_snapshot()?; + assert_eq!(flowstate_document::paragraph_text(&projection, 0), "before"); + assert_eq!(flowstate_document::paragraph_text(&projection, 1), "af!ter"); + Ok(()) + } + + #[test] + fn remote_import_reports_pending_dependencies() -> Result<()> { + let source = flowstate_document::new_loro_document("Source")?; + let empty_vv = VersionVector::default(); + body_text(&source).insert(1, "first")?; + source.commit(); + let mid_vv = source.state_vv(); + body_text(&source).insert(6, " second")?; + source.commit(); + let second_only = source.export(ExportMode::updates(&mid_vv))?; + + let mut target = CrdtRuntime::new_empty("Target")?; + let events = target.import_remote_update(&second_only)?; + assert!(matches!(events.first(), Some(RuntimeEvent::RemoteUpdateApplied { pending: Some(_), .. }))); + + let first_update = source.export(ExportMode::updates(&empty_vv))?; + let events = target.import_remote_update(&first_update)?; + assert!(matches!(events.first(), Some(RuntimeEvent::RemoteUpdateApplied { pending: None, .. }))); + Ok(()) + } + #[test] + fn projected_document_runtime_starts_without_eager_package_or_reprojection() -> Result<()> { + let mut source = flowstate_document::document_from_input_blocks( + flowstate_document::DocumentTheme::default(), + vec![InputBlock::Paragraph(input_paragraph("imported"))], + ); + source.theme.zoom_factor = 1.75; + let runtime = CrdtRuntime::from_document_projection(&source, "Imported")?; + + assert!(runtime.package.is_none()); + assert_eq!(runtime.projection.frontier, runtime.doc.state_frontiers().encode()); + assert_eq!(runtime.projection.theme.zoom_factor, 1.75); + assert_eq!(flowstate_document::paragraph_text(&runtime.projection, 0), "imported"); + Ok(()) + } + + #[test] + fn projection_index_materializes_paragraph_and_block_anchor_lookups() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 3, + inherited_style: ParagraphStyle::Normal, + })?; + let projection = runtime.projection_snapshot()?; + let index = ProjectionRuntimeIndex::from_projection(&projection); + + // §24 paragraph metadata index agrees with the linear `paragraph_ids` scan. + for (expected_ix, id) in projection.ids.paragraph_ids.iter().enumerate() { + assert_eq!(index.paragraph_metadata_by_id.get(id).copied(), Some(expected_ix)); + assert_eq!( + index.paragraph_index_for_id(&projection, *id), + projection + .ids + .paragraph_ids + .iter() + .position(|candidate| candidate == id), + ); + } + // §24 block anchor index agrees with the linear `block_ids` scan. + for (expected_ix, id) in projection.ids.block_ids.iter().enumerate() { + assert_eq!(index.block_anchor_by_id.get(id).copied(), Some(expected_ix)); + assert_eq!( + index.block_index_for_id(&projection, *id), + projection + .ids + .block_ids + .iter() + .position(|candidate| candidate == id), + ); + } + // Absent ids fall back to `None` through both the index and the scan. + assert_eq!(index.paragraph_index_for_id(&projection, flowstate_document::new_paragraph_id()), None); + assert_eq!(index.block_index_for_id(&projection, flowstate_document::new_block_id()), None); + + // One search-unit span per block; one style-interval list per paragraph. + assert_eq!(index.search_unit_spans.len(), projection.blocks.len()); + assert_eq!(index.style_runs_by_paragraph.len(), projection.paragraphs.len()); + Ok(()) + } + + #[test] + fn projection_index_maps_changed_ranges_to_search_units() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?; + runtime.command(SemanticCommand::SplitParagraph { + unicode_index: 3, + inherited_style: ParagraphStyle::Normal, + })?; + let projection = runtime.projection_snapshot()?; + let index = ProjectionRuntimeIndex::from_projection(&projection); + + // Body text is "\nhe\nllo": unit 0 covers [1,3), unit 1 covers [4,7). + let first = index.search_units_for_changed_ranges(&[ProjectionTextRange { + flow_id: ROOT_BODY_FLOW_ID.to_string(), + unicode_start: 1, + unicode_len: 1, + }]); + assert_eq!(first, vec![0]); + let second = index.search_units_for_changed_ranges(&[ProjectionTextRange { + flow_id: ROOT_BODY_FLOW_ID.to_string(), + unicode_start: 4, + unicode_len: 1, + }]); + assert_eq!(second, vec![1]); + // A non-body flow never matches a body search-unit span. + assert!( + index + .search_units_for_changed_ranges(&[ProjectionTextRange { + flow_id: "flow.other".to_string(), + unicode_start: 1, + unicode_len: 5, + }]) + .is_empty() + ); + Ok(()) + } + + #[test] + fn projection_index_style_intervals_resolve_run_styles() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + let styles = RunStyles { + semantic: RunSemanticStyle::Custom(2), + ..RunStyles::default() + }; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "styled".to_string(), + styles, + })?; + let projection = runtime.projection_snapshot()?; + let index = ProjectionRuntimeIndex::from_projection(&projection); + + // The style interval covering the paragraph's leading byte carries its run styles. + assert_eq!(index.run_styles_at(0, 0), Some(styles)); + assert_eq!(index.run_styles_at(0, 0), projection.paragraphs[0].runs.first().map(|run| run.styles),); + // Bytes beyond the paragraph text have no covering interval. + assert_eq!(index.run_styles_at(0, 1_000), None); + Ok(()) + } + + #[test] + fn projection_index_indexes_image_asset_refs_and_table_cells() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertImage { + unicode_index: 1, + asset_id: 7, + alt_text: "alt".to_string(), + caption: None, + sizing: InputImageSizing::Intrinsic, + alignment: InputBlockAlignment::Center, + })?; + runtime.command(SemanticCommand::InsertTable { + unicode_index: 2, + rows: 2, + columns: 2, + column_widths: vec![InputTableColumnWidth::FixedPx(120), InputTableColumnWidth::Fraction(1)], + header_row: true, + })?; + let projection = runtime.projection_snapshot()?; + let index = ProjectionRuntimeIndex::from_projection(&projection); + + // §24 asset reference index: the image block id is recorded under its asset. + let image_block_ix = projection + .blocks + .iter() + .position(|block| matches!(block, Block::Image(_))) + .expect("image block present"); + let image_block_id = projection.ids.block_ids[image_block_ix]; + let asset_refs = index + .asset_refs_by_id + .get(&AssetId(7)) + .expect("asset reference indexed"); + assert_eq!(asset_refs.len(), 1); + assert!(asset_refs.contains(&image_block_id)); + + // §24 table row/column/cell index: 2x2 table → 2 rows, 2 columns, 4 cells. + let table_block_ix = projection + .blocks + .iter() + .position(|block| matches!(block, Block::Table(_))) + .expect("table block present"); + let table_block_id = projection.ids.block_ids[table_block_ix]; + let entry = index + .table_cells_by_block + .get(&table_block_id) + .expect("table indexed"); + assert_eq!(entry.row_ids.len(), 2); + assert_eq!(entry.column_ids.len(), 2); + assert_eq!(entry.cells.len(), 4); + assert!(entry.cells.contains(&(1, 1))); + + // §24 search unit index: every object block contributes a paragraph-less span. + let object_blocks = projection + .blocks + .iter() + .filter(|block| !matches!(block, Block::Paragraph(_))) + .count(); + assert_eq!( + index + .search_unit_spans + .iter() + .filter(|span| span.paragraph.is_none()) + .count(), + object_blocks, + ); + Ok(()) + } + + #[test] + fn projection_index_cursor_cache_clears_on_incremental_update() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Runtime")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "hello".to_string(), + styles: RunStyles::default(), + })?; + // Seed a stand-in cache entry, then drive a further incremental edit. Positions + // can shift on any edit, so the cache must be cleared (rebuilt empty) afterward. + runtime + .projection_index + .cursor_resolution_cache + .insert(vec![0xde, 0xad], DocumentOffset::default()); + assert!(!runtime.projection_index.cursor_resolution_cache.is_empty()); + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "x".to_string(), + styles: RunStyles::default(), + })?; + assert!(runtime.projection_index.cursor_resolution_cache.is_empty()); + Ok(()) + } + + #[test] + fn presence_after_affinity_sticks_to_following_text_across_inserts() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Presence")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "ab".to_string(), + styles: RunStyles::default(), + })?; + let offset = DocumentOffset { paragraph: 0, byte: 1 }; + let selection = EditorSelection::collapsed_with(offset, gpui_flowtext::SelectionAffinity::After, gpui_flowtext::VisualGravity::Downstream); + let presence = runtime + .presence_selection(&selection) + .expect("presence selection should encode"); + + runtime.command(SemanticCommand::InsertText { + unicode_index: 2, + text: "X".to_string(), + styles: RunStyles::default(), + })?; + let carets = runtime.resolve_presence_carets(vec![RuntimePresenceCaretRequest { + selection: presence, + color_rgb: 0xabcdef, + }]); + + assert_eq!(carets.carets.len(), 1); + assert_eq!(carets.carets[0].offset, DocumentOffset { paragraph: 0, byte: 2 }); + assert_eq!(carets.carets[0].visual_gravity, gpui_flowtext::VisualGravity::Downstream); + Ok(()) + } + + #[test] + fn presence_before_affinity_sticks_to_preceding_text_across_inserts() -> Result<()> { + let mut runtime = CrdtRuntime::new_empty("Presence")?; + runtime.command(SemanticCommand::InsertText { + unicode_index: 1, + text: "ab".to_string(), + styles: RunStyles::default(), + })?; + let offset = DocumentOffset { paragraph: 0, byte: 1 }; + let selection = EditorSelection::collapsed_with(offset, gpui_flowtext::SelectionAffinity::Before, gpui_flowtext::VisualGravity::Upstream); + let presence = runtime + .presence_selection(&selection) + .expect("presence selection should encode"); + + runtime.command(SemanticCommand::InsertText { + unicode_index: 2, + text: "X".to_string(), + styles: RunStyles::default(), + })?; + let carets = runtime.resolve_presence_carets(vec![RuntimePresenceCaretRequest { + selection: presence, + color_rgb: 0xabcdef, + }]); + + assert_eq!(carets.carets.len(), 1); + assert_eq!(carets.carets[0].offset, offset); + assert_eq!(carets.carets[0].visual_gravity, gpui_flowtext::VisualGravity::Upstream); + Ok(()) + } +} diff --git a/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs b/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs new file mode 100644 index 00000000..1a7b0a7a --- /dev/null +++ b/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs @@ -0,0 +1,723 @@ +use flowstate_document::{ + Block, DocumentProjection, HighlightStyle, InputBlock, InputParagraph, InputRun, MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, + MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, OBJECT_REPLACEMENT, Paragraph, ParagraphStyle, ProjectionPatch, ProjectionStructuralBlock, + ProjectionTextDelta, RunSemanticStyle, RunStyles, input_block_from_block, loro_schema::body_text, new_block_id, new_paragraph_id, + paragraph_text, +}; +use loro::{LoroDoc, LoroValue}; +use rustc_hash::FxHashMap; +use std::collections::BTreeSet; + +use super::{ProjectionInvalidation, paragraph_style_from_attrs}; + +pub(super) fn projection_patches_between(before: &DocumentProjection, after: &DocumentProjection) -> Option> { + let mut patches = Vec::new(); + append_asset_patches(before, after, &mut patches); + + let before_ids = &before.ids.block_ids; + let after_ids = &after.ids.block_ids; + if before_ids == after_ids { + append_same_shape_patches(before, after, &mut patches)?; + return Some(patches); + } + + if let Some((from, to)) = single_block_move(before_ids, after_ids) { + if !retained_blocks_unchanged(before, after) { + return None; + } + patches.push(ProjectionPatch::MoveBlock { from, to }); + return Some(patches); + } + + let prefix = common_id_prefix(before_ids, after_ids); + let suffix = common_id_suffix(before_ids, after_ids, prefix); + let before_end = before_ids.len().saturating_sub(suffix); + let after_end = after_ids.len().saturating_sub(suffix); + if before_end > prefix { + patches.push(ProjectionPatch::DeleteBlocks { + row: prefix, + count: before_end - prefix, + }); + } + if after_end > prefix { + patches.push(ProjectionPatch::InsertBlocks { + row: prefix, + blocks: structural_blocks(after, prefix..after_end), + }); + } + if !retained_edge_blocks_unchanged(before, after, prefix, suffix) { + return None; + } + Some(patches) +} + +fn append_same_shape_patches(before: &DocumentProjection, after: &DocumentProjection, patches: &mut Vec) -> Option<()> { + if before.blocks.len() != after.blocks.len() { + return None; + } + let mut before_paragraph_ix = 0usize; + let mut after_paragraph_ix = 0usize; + for (row, (before_block, after_block)) in before.blocks.iter().zip(after.blocks.iter()).enumerate() { + match (before_block, after_block) { + (Block::Paragraph(before_paragraph), Block::Paragraph(after_paragraph)) => { + append_paragraph_patch( + before, + after, + row, + before_paragraph_ix, + after_paragraph_ix, + before_paragraph, + after_paragraph, + patches, + ); + before_paragraph_ix += 1; + after_paragraph_ix += 1; + }, + (Block::Image(_) | Block::Equation(_) | Block::Table(_), Block::Image(_) | Block::Equation(_) | Block::Table(_)) => { + if before_block != after_block { + patches.push(ProjectionPatch::ReplaceObjectBlock { + row, + block: structural_block(after, row, after_paragraph_ix), + }); + } + }, + _ => return None, + } + } + Some(()) +} + +#[allow(clippy::too_many_arguments, reason = "paragraph diffs need both projection coordinates and payloads")] +fn append_paragraph_patch( + before: &DocumentProjection, + after: &DocumentProjection, + row: usize, + before_paragraph_ix: usize, + after_paragraph_ix: usize, + before_paragraph: &Paragraph, + after_paragraph: &Paragraph, + patches: &mut Vec, +) { + let before_text = paragraph_text(before, before_paragraph_ix); + let after_text = paragraph_text(after, after_paragraph_ix); + if before_text != after_text { + patches.push(ProjectionPatch::ParagraphText { + row, + new: input_paragraph(after, after_paragraph_ix, after_paragraph), + delta_utf8: text_delta_between(&before_text, &after_text), + }); + return; + } + if before_paragraph.style != after_paragraph.style { + patches.push(ProjectionPatch::ParagraphStyle { + row, + style: after_paragraph.style, + }); + } + if before_paragraph.runs != after_paragraph.runs { + patches.push(ProjectionPatch::ParagraphRuns { + row, + runs: after_paragraph.runs.clone(), + }); + } +} + +fn input_paragraph(document: &DocumentProjection, paragraph_ix: usize, paragraph: &Paragraph) -> InputParagraph { + let text = paragraph_text(document, paragraph_ix); + let mut byte = 0usize; + let runs = paragraph + .runs + .iter() + .map(|run| { + let start = byte; + let end = start.saturating_add(run.len).min(text.len()); + byte = end; + InputRun { + text: text.get(start..end).unwrap_or_default().to_string(), + styles: run.styles, + } + }) + .collect(); + InputParagraph { + style: paragraph.style, + runs, + } +} + +fn structural_blocks(document: &DocumentProjection, range: std::ops::Range) -> Vec { + let mut paragraph_ix = document + .blocks + .iter() + .take(range.start) + .filter(|block| matches!(block, Block::Paragraph(_))) + .count(); + range + .map(|row| { + let structural = structural_block(document, row, paragraph_ix); + if matches!(document.blocks.get(row), Some(Block::Paragraph(_))) { + paragraph_ix += 1; + } + structural + }) + .collect() +} + +fn structural_block(document: &DocumentProjection, row: usize, paragraph_ix: usize) -> ProjectionStructuralBlock { + let block = document + .blocks + .get(row) + .map(input_block_from_block) + .unwrap_or_else(|| { + InputBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Normal, + runs: Vec::new(), + }) + }); + ProjectionStructuralBlock { + block_id: document + .ids + .block_ids + .get(row) + .copied() + .unwrap_or_else(new_block_id), + paragraph_id: matches!(&block, InputBlock::Paragraph(_)).then(|| { + document + .ids + .paragraph_ids + .get(paragraph_ix) + .copied() + .unwrap_or_else(new_paragraph_id) + }), + block, + } +} + +fn append_asset_patches(before: &DocumentProjection, after: &DocumentProjection, patches: &mut Vec) { + for (id, record) in &after.assets.assets { + if before.assets.assets.get(id) != Some(record) { + patches.push(ProjectionPatch::AssetArrived { + id: *id, + record: record.clone(), + }); + } + } +} + +fn single_block_move(before: &[flowstate_document::BlockId], after: &[flowstate_document::BlockId]) -> Option<(usize, usize)> { + if before.len() != after.len() || before.len() < 2 { + return None; + } + + let mut before_positions = FxHashMap::default(); + for (index, id) in before.iter().copied().enumerate() { + if before_positions.insert(id, index).is_some() { + return None; + } + } + + let mut moved = None; + for (after_index, id) in after.iter().copied().enumerate() { + let before_index = *before_positions.get(&id)?; + if before_index != after_index { + match moved { + Some((existing_id, _, _)) if existing_id != id => return None, + Some(_) => {}, + None => moved = Some((id, before_index, after_index)), + } + } + } + + let (moved_id, from, _) = moved?; + let mut candidate = before.to_vec(); + candidate.remove(from); + let to = after.iter().position(|id| *id == moved_id)?; + candidate.insert(to, moved_id); + (candidate == after).then_some((from, to)) +} + +fn retained_blocks_unchanged(before: &DocumentProjection, after: &DocumentProjection) -> bool { + let mut before_blocks = FxHashMap::default(); + for row in 0..before.ids.block_ids.len() { + let Some(id) = before.ids.block_ids.get(row).copied() else { + return false; + }; + let Some(block) = input_block_at(before, row) else { + return false; + }; + before_blocks.insert(id, block); + } + for row in 0..after.ids.block_ids.len() { + let Some(id) = after.ids.block_ids.get(row).copied() else { + return false; + }; + let Some(before_block) = before_blocks.get(&id) else { + return false; + }; + let Some(after_block) = input_block_at(after, row) else { + return false; + }; + if before_block != &after_block { + return false; + } + } + true +} + +fn retained_edge_blocks_unchanged(before: &DocumentProjection, after: &DocumentProjection, prefix: usize, suffix: usize) -> bool { + let before_len = before.ids.block_ids.len(); + let after_len = after.ids.block_ids.len(); + for row in 0..prefix { + if input_block_at(before, row) != input_block_at(after, row) { + return false; + } + } + for offset in 0..suffix { + let before_row = before_len.saturating_sub(suffix) + offset; + let after_row = after_len.saturating_sub(suffix) + offset; + if input_block_at(before, before_row) != input_block_at(after, after_row) { + return false; + } + } + true +} + +fn input_block_at(document: &DocumentProjection, row: usize) -> Option { + let block = document.blocks.get(row)?; + match block { + Block::Paragraph(paragraph) => { + let paragraph_ix = document + .blocks + .iter() + .take(row) + .filter(|block| matches!(block, Block::Paragraph(_))) + .count(); + Some(InputBlock::Paragraph(input_paragraph(document, paragraph_ix, paragraph))) + }, + _ => Some(input_block_from_block(block)), + } +} + +fn common_id_prefix(left: &[flowstate_document::BlockId], right: &[flowstate_document::BlockId]) -> usize { + left + .iter() + .zip(right) + .take_while(|(left, right)| left == right) + .count() +} + +fn common_id_suffix(left: &[flowstate_document::BlockId], right: &[flowstate_document::BlockId], prefix: usize) -> usize { + left + .iter() + .skip(prefix) + .rev() + .zip(right.iter().skip(prefix).rev()) + .take_while(|(left, right)| left == right) + .count() +} + +fn text_delta_between(before: &str, after: &str) -> Vec { + let prefix = common_prefix_byte_len(before, after); + let suffix = common_suffix_byte_len(before, after, prefix); + text_delta( + prefix, + before.len().saturating_sub(prefix + suffix), + after.len().saturating_sub(prefix + suffix), + suffix, + ) +} + +pub(super) fn remote_body_text_patch( + projection: &DocumentProjection, + before: &str, + after: &str, + doc: &LoroDoc, + frontier_before: Vec, + frontier_after: Vec, +) -> Option<(Vec, ProjectionInvalidation)> { + if before == after { + return None; + } + + let prefix = common_prefix_byte_len(before, after); + let suffix = common_suffix_byte_len(before, after, prefix); + let before_changed_end = before.len().checked_sub(suffix)?; + let after_changed_end = after.len().checked_sub(suffix)?; + if prefix > before_changed_end || prefix > after_changed_end { + return None; + } + + let before_changed = before.get(prefix..before_changed_end)?; + let after_changed = after.get(prefix..after_changed_end)?; + if contains_structural_body_char(before_changed) || contains_structural_body_char(after_changed) { + return None; + } + + let before_location = paragraph_text_location(before, prefix)?; + let after_location = paragraph_text_location(after, prefix)?; + if before_location.paragraph_ix != after_location.paragraph_ix { + return None; + } + + let old_paragraph_len = before_location + .paragraph_end_byte + .checked_sub(before_location.paragraph_start_byte)?; + let prefix_in_paragraph = prefix.checked_sub(before_location.paragraph_start_byte)?; + let old_changed_len = before_changed_end.checked_sub(prefix)?; + let new_changed_len = after_changed_end.checked_sub(prefix)?; + let trailing_retain = old_paragraph_len + .checked_sub(prefix_in_paragraph)? + .checked_sub(old_changed_len)?; + + let new_paragraph = body_input_paragraph(doc, before_location.paragraph_ix)?; + let delta_utf8 = text_delta(prefix_in_paragraph, old_changed_len, new_changed_len, trailing_retain); + let unicode_start = before[..prefix].chars().count(); + let unicode_len = before_changed + .chars() + .count() + .max(after_changed.chars().count()); + let invalidation = ProjectionInvalidation::body_text(frontier_before, frontier_after, unicode_start, unicode_len); + Some(( + vec![ProjectionPatch::ParagraphText { + row: flowstate_document::block_ix_for_paragraph(projection, before_location.paragraph_ix)?, + new: new_paragraph, + delta_utf8, + }], + invalidation, + )) +} + +pub(super) fn remote_body_projection_patches( + projection: &DocumentProjection, + before: &str, + after: &str, + doc: &LoroDoc, + invalidation: &ProjectionInvalidation, +) -> Option> { + if invalidation.rebuild_required || !invalidation.changed_sections.is_empty() { + return None; + } + + if before != after { + let mut patches = remote_body_text_patch( + projection, + before, + after, + doc, + invalidation.frontier_before.clone(), + invalidation.frontier_after.clone(), + )? + .0; + if !invalidation.changed_blocks.is_empty() + || !invalidation.changed_tables.is_empty() + || invalidation + .changed_flows + .iter() + .any(|flow| flow != flowstate_document::ROOT_BODY_FLOW_ID) + { + patches.extend(remote_object_projection_patches(projection, doc)?); + } + return Some(patches); + } + + if !invalidation.changed_blocks.is_empty() + || !invalidation.changed_tables.is_empty() + || invalidation + .changed_flows + .iter() + .any(|flow| flow != flowstate_document::ROOT_BODY_FLOW_ID) + { + return remote_object_projection_patches(projection, doc); + } + + let mut touched = BTreeSet::new(); + for range in &invalidation.changed_text_ranges { + if range.flow_id != flowstate_document::ROOT_BODY_FLOW_ID { + return None; + } + touched.insert(paragraph_index_at_unicode(after, range.unicode_start)); + touched.insert(paragraph_index_at_unicode(after, range.unicode_start.saturating_add(range.unicode_len))); + } + if touched.is_empty() { + return Some(Vec::new()); + } + paragraph_projection_patches(projection, doc, touched) +} + +pub(super) fn remote_nonstructural_projection_patches( + projection: &DocumentProjection, + doc: &LoroDoc, + invalidation: &ProjectionInvalidation, + touched_paragraphs: &[usize], +) -> Option> { + if invalidation.rebuild_required || !invalidation.changed_sections.is_empty() { + return None; + } + + let mut patches = paragraph_projection_patches(projection, doc, touched_paragraphs.iter().copied())?; + if !invalidation.changed_blocks.is_empty() + || !invalidation.changed_tables.is_empty() + || invalidation + .changed_flows + .iter() + .any(|flow| flow != flowstate_document::ROOT_BODY_FLOW_ID) + { + patches.extend(remote_object_projection_patches(projection, doc)?); + } + Some(patches) +} + +fn paragraph_projection_patches( + projection: &DocumentProjection, + doc: &LoroDoc, + touched_paragraphs: impl IntoIterator, +) -> Option> { + let mut patches = Vec::new(); + for paragraph_ix in touched_paragraphs { + let old = projection.paragraphs.get(paragraph_ix)?; + let old_input = input_paragraph(projection, paragraph_ix, old); + let new_input = body_input_paragraph(doc, paragraph_ix)?; + let row = flowstate_document::block_ix_for_paragraph(projection, paragraph_ix)?; + let old_text = old_input + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(); + let new_text = new_input + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(); + if old_text != new_text { + patches.push(ProjectionPatch::ParagraphText { + row, + delta_utf8: text_delta_between(&old_text, &new_text), + new: new_input, + }); + continue; + } + if old_input.style != new_input.style { + patches.push(ProjectionPatch::ParagraphStyle { row, style: new_input.style }); + } + let new_runs = flowstate_document::document_from_input_blocks(projection.theme.clone(), vec![InputBlock::Paragraph(new_input)]) + .paragraphs + .first()? + .runs + .clone(); + if old.runs != new_runs { + patches.push(ProjectionPatch::ParagraphRuns { row, runs: new_runs }); + } + } + Some(patches) +} + +fn remote_object_projection_patches(projection: &DocumentProjection, doc: &LoroDoc) -> Option> { + let projected = flowstate_document::object_input_blocks_from_loro(doc).ok()?; + let mut existing = projection + .blocks + .iter() + .enumerate() + .filter_map(|(row, block)| { + if matches!(block, Block::Paragraph(_)) { + return None; + } + Some((projection.ids.block_ids.get(row).copied()?, row, input_block_from_block(block))) + }) + .collect::>(); + existing.sort_by_key(|(id, _, _)| id.0); + if existing.len() != projected.len() + || existing + .iter() + .zip(&projected) + .any(|((existing_id, _, _), (projected_id, _))| existing_id != projected_id) + { + return None; + } + Some( + existing + .into_iter() + .zip(projected) + .filter_map(|((block_id, row, before), (_, after))| { + (before != after).then_some(ProjectionPatch::ReplaceObjectBlock { + row, + block: ProjectionStructuralBlock { + block_id, + paragraph_id: None, + block: after, + }, + }) + }) + .collect(), + ) +} + +fn paragraph_index_at_unicode(body: &str, unicode_index: usize) -> usize { + let mut paragraph_ix = 0usize; + let mut seen_sentinel = false; + for (index, ch) in body.chars().enumerate() { + if index >= unicode_index { + break; + } + if ch == '\n' { + if seen_sentinel { + paragraph_ix += 1; + } else { + seen_sentinel = true; + } + } + } + paragraph_ix +} + +fn contains_structural_body_char(text: &str) -> bool { + text + .chars() + .any(|ch| ch == '\n' || ch == OBJECT_REPLACEMENT) +} + +#[derive(Clone, Copy)] +struct ParagraphTextLocation { + paragraph_ix: usize, + paragraph_start_byte: usize, + paragraph_end_byte: usize, +} + +fn paragraph_text_location(body: &str, body_byte: usize) -> Option { + if body_byte > body.len() || !body.is_char_boundary(body_byte) { + return None; + } + let sentinel_end = body.find('\n')? + '\n'.len_utf8(); + if body_byte < sentinel_end { + return None; + } + let paragraph_start_byte = body[..body_byte] + .rfind('\n') + .map_or(sentinel_end, |index| index + '\n'.len_utf8()); + let paragraph_end_byte = body[body_byte..] + .find('\n') + .map_or(body.len(), |relative| body_byte + relative); + let paragraph_ix = body[..paragraph_start_byte] + .chars() + .filter(|ch| *ch == '\n') + .count() + .saturating_sub(1); + Some(ParagraphTextLocation { + paragraph_ix, + paragraph_start_byte, + paragraph_end_byte, + }) +} + +fn text_delta(prefix_retain: usize, delete_len: usize, insert_len: usize, trailing_retain: usize) -> Vec { + let mut delta = Vec::new(); + if prefix_retain > 0 { + delta.push(ProjectionTextDelta::Retain(prefix_retain)); + } + if delete_len > 0 { + delta.push(ProjectionTextDelta::Delete(delete_len)); + } + if insert_len > 0 { + delta.push(ProjectionTextDelta::Insert(insert_len)); + } + if trailing_retain > 0 { + delta.push(ProjectionTextDelta::Retain(trailing_retain)); + } + delta +} + +fn common_prefix_byte_len(left: &str, right: &str) -> usize { + let mut len = 0; + for ((left_ix, left_ch), (_, right_ch)) in left.char_indices().zip(right.char_indices()) { + if left_ch != right_ch { + break; + } + len = left_ix + left_ch.len_utf8(); + } + len +} + +fn common_suffix_byte_len(left: &str, right: &str, prefix: usize) -> usize { + let mut len = 0; + for ((left_ix, left_ch), (right_ix, right_ch)) in left.char_indices().rev().zip(right.char_indices().rev()) { + if left_ix < prefix || right_ix < prefix || left_ch != right_ch { + break; + } + len += left_ch.len_utf8(); + } + len +} + +pub(super) fn body_input_paragraph(doc: &LoroDoc, target_paragraph_ix: usize) -> Option { + let text = body_text(doc); + let mut current = InputParagraph { + style: ParagraphStyle::Normal, + runs: Vec::new(), + }; + let mut pending_style = ParagraphStyle::Normal; + let mut seen_sentinel = false; + let mut paragraph_ix = 0usize; + + for item in text.to_delta() { + let loro::TextDelta::Insert { insert, attributes } = item else { + continue; + }; + let run_styles = run_styles_from_attrs(attributes.as_ref()); + for ch in insert.chars() { + if ch == '\n' { + let style = paragraph_style_from_attrs(attributes.as_ref()).unwrap_or(pending_style); + if !seen_sentinel { + seen_sentinel = true; + pending_style = style; + current.style = style; + } else { + if paragraph_ix == target_paragraph_ix { + return Some(current); + } + paragraph_ix += 1; + current = InputParagraph { style, runs: Vec::new() }; + pending_style = style; + } + } else if ch != OBJECT_REPLACEMENT { + push_input_char(&mut current, ch, run_styles); + } + } + } + + (seen_sentinel && paragraph_ix == target_paragraph_ix).then_some(current) +} + +fn push_input_char(paragraph: &mut InputParagraph, ch: char, styles: RunStyles) { + if let Some(last) = paragraph.runs.last_mut() + && last.styles == styles + { + last.text.push(ch); + return; + } + paragraph.runs.push(InputRun { + text: ch.to_string(), + styles, + }); +} + +fn run_styles_from_attrs(attrs: Option<&FxHashMap>) -> RunStyles { + let mut styles = RunStyles::default(); + let Some(attrs) = attrs else { + return styles; + }; + if let Some(LoroValue::I64(slot)) = attrs.get(MARK_RUN_SEMANTIC_STYLE) + && let Ok(slot) = u8::try_from(*slot) + { + styles.semantic = RunSemanticStyle::Custom(slot); + } + if let Some(LoroValue::I64(slot)) = attrs.get(MARK_HIGHLIGHT_STYLE) + && let Ok(slot) = u8::try_from(*slot) + { + styles.highlight = Some(HighlightStyle::Custom(slot)); + } + if matches!(attrs.get(MARK_DIRECT_UNDERLINE), Some(LoroValue::Bool(true))) { + styles.direct_underline = true; + } + if matches!(attrs.get(MARK_STRIKETHROUGH), Some(LoroValue::Bool(true))) { + styles.strikethrough = true; + } + styles +} diff --git a/crates/flowstate-collab/src/crdt_runtime/types.rs b/crates/flowstate-collab/src/crdt_runtime/types.rs new file mode 100644 index 00000000..109a353a --- /dev/null +++ b/crates/flowstate-collab/src/crdt_runtime/types.rs @@ -0,0 +1,272 @@ +use flowstate_document::{ + DocumentPackage, DocumentProjection, InputBlockAlignment, InputEquationDisplay, InputImageSizing, InputTableColumnWidth, ParagraphStyle, + ProjectionPatch, ROOT_BODY_FLOW_ID, RunStyles, +}; +use gpui_flowtext::{EditorSelection, ExternalCaret}; +use loro::VersionRange; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug)] +pub struct StaleProjectionError { + pub expected_frontier_len: usize, + pub current_frontier_len: usize, +} + +impl std::fmt::Display for StaleProjectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "editor command batch was constructed at a stale projection frontier (expected {} bytes, current frontier has {} bytes)", + self.expected_frontier_len, self.current_frontier_len, + ) + } +} + +impl std::error::Error for StaleProjectionError {} + +#[derive(Clone, Debug)] +pub struct RuntimeAssetMetadata { + pub asset_id: u128, + pub content_hash: [u8; 32], + pub mime_type: String, + pub original_name: Option, + pub byte_length: u64, +} + +#[derive(Clone, Debug)] +pub struct RuntimeRevisionInfo { + pub revision_id: u128, + pub title: String, + pub summary: String, + pub created_at_unix_secs: i64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct UndoSelectionSnapshot { + pub anchor_cursor: Vec, + pub head_cursor: Vec, + pub anchor_affinity: UndoSelectionAffinity, + pub head_affinity: UndoSelectionAffinity, + pub direction: UndoSelectionDirection, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum UndoSelectionAffinity { + Before, + After, + #[default] + Neutral, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum UndoSelectionDirection { + Forward, + Backward, + #[default] + None, +} + +#[derive(Debug, Default)] +pub(super) struct UndoSelectionState { + pub(super) pending_selection: Option>, + pub(super) restored_selection: Option, +} + +#[derive(Clone, Debug)] +pub enum SemanticCommand { + InsertText { + unicode_index: usize, + text: String, + styles: RunStyles, + }, + DeleteRange { + unicode_index: usize, + unicode_len: usize, + }, + SplitParagraph { + unicode_index: usize, + inherited_style: ParagraphStyle, + }, + SetParagraphStyle { + boundary_unicode_index: usize, + style: ParagraphStyle, + }, + SetRunStyles { + unicode_range: std::ops::Range, + styles: RunStyles, + }, + InsertImage { + unicode_index: usize, + asset_id: u128, + alt_text: String, + caption: Option, + sizing: InputImageSizing, + alignment: InputBlockAlignment, + }, + InsertEquation { + unicode_index: usize, + source: String, + display: InputEquationDisplay, + }, + InsertTable { + unicode_index: usize, + rows: usize, + columns: usize, + column_widths: Vec, + header_row: bool, + }, + OpenRevision { + revision_id: u128, + }, + ForkRevision { + revision_id: u128, + }, + Undo, + Redo, +} + +#[derive(Debug)] +pub enum RuntimeEvent { + LocalUpdate { + bytes: Vec, + frontier: Vec, + version_vector: Vec, + }, + RemoteUpdateApplied { + pending: Option, + frontier: Vec, + version_vector: Vec, + }, + RevisionOpened { + revision_id: u128, + document: Box, + }, + RevisionForked { + revision_id: u128, + document: Box, + package: Box, + }, + SelectionRestored { + selection: EditorSelection, + }, + ProjectionUpdated { + document: Box, + invalidation: ProjectionInvalidation, + frontier: Vec, + version_vector: Vec, + }, + ProjectionPatched { + patches: Vec, + invalidation: ProjectionInvalidation, + frontier: Vec, + version_vector: Vec, + }, +} + +impl RuntimeEvent { + #[must_use] + pub fn frontier(&self) -> Option<&[u8]> { + match self { + Self::LocalUpdate { frontier, .. } + | Self::RemoteUpdateApplied { frontier, .. } + | Self::ProjectionUpdated { frontier, .. } + | Self::ProjectionPatched { frontier, .. } => Some(frontier), + Self::RevisionOpened { document, .. } | Self::RevisionForked { document, .. } => Some(&document.frontier), + Self::SelectionRestored { .. } => None, + } + } +} + +#[derive(Clone, Debug)] +pub struct RuntimePresenceCaretRequest { + pub selection: crate::presence::PresenceSelection, + pub color_rgb: u32, +} + +#[derive(Clone, Debug)] +pub struct RuntimePresenceCarets { + pub carets: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectionFallbackStats { + pub total: u64, + pub by_reason: BTreeMap, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectionInvalidation { + pub frontier_before: Vec, + pub frontier_after: Vec, + pub changed_flows: Vec, + pub changed_text_ranges: Vec, + pub changed_blocks: Vec, + pub changed_tables: Vec, + pub changed_assets: Vec, + pub changed_sections: Vec, + pub rebuild_required: bool, + pub fallback_reason: Option<&'static str>, + /// §23: set when at least one drained, in-epoch subscription summary carried a + /// remote origin (e.g. an imported update). Lets downstream consumers bias + /// toward conservative handling/telemetry without forcing a full rebuild for + /// ordinary remote text edits (the incremental remote fast paths still apply). + /// Defaults to `false` via `Default` for every existing constructor. + pub has_remote_origin: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectionTextRange { + pub flow_id: String, + pub unicode_start: usize, + pub unicode_len: usize, +} + +impl ProjectionInvalidation { + pub(super) fn body_text(frontier_before: Vec, frontier_after: Vec, unicode_start: usize, unicode_len: usize) -> Self { + Self { + frontier_before, + frontier_after, + changed_flows: vec![ROOT_BODY_FLOW_ID.to_string()], + changed_text_ranges: vec![ProjectionTextRange { + flow_id: ROOT_BODY_FLOW_ID.to_string(), + unicode_start, + unicode_len, + }], + ..Self::default() + } + } + + pub(super) fn body_style(frontier_before: Vec, frontier_after: Vec, unicode_start: usize, unicode_len: usize) -> Self { + Self::body_text(frontier_before, frontier_after, unicode_start, unicode_len) + } + + pub(super) fn body_object(frontier_before: Vec, frontier_after: Vec, unicode_index: usize, block_kind: &'static str) -> Self { + Self { + frontier_before, + frontier_after, + changed_flows: vec![ROOT_BODY_FLOW_ID.to_string()], + changed_text_ranges: vec![ProjectionTextRange { + flow_id: ROOT_BODY_FLOW_ID.to_string(), + unicode_start: unicode_index, + unicode_len: 1, + }], + changed_blocks: vec![block_kind.to_string()], + changed_tables: (block_kind == "table") + .then(|| block_kind.to_string()) + .into_iter() + .collect(), + ..Self::default() + } + } + + pub(super) fn full_rebuild(frontier_before: Vec, frontier_after: Vec, reason: &'static str) -> Self { + Self { + frontier_before, + frontier_after, + rebuild_required: true, + fallback_reason: Some(reason), + ..Self::default() + } + } +} diff --git a/crates/flowstate-collab/src/crdt_runtime_actor.rs b/crates/flowstate-collab/src/crdt_runtime_actor.rs new file mode 100644 index 00000000..633a65c8 --- /dev/null +++ b/crates/flowstate-collab/src/crdt_runtime_actor.rs @@ -0,0 +1,449 @@ +use std::{io, path::PathBuf, thread}; + +use anyhow::{Context as _, Result, anyhow}; +use async_channel::{Receiver, Sender}; +use flowstate_document::{AssetRecord, DocumentProjection}; +use gpui_flowtext::{EditorSelection, SemanticEditCommand as EditorSemanticCommand}; +use loro::{ExportMode, VersionVector}; + +use crate::crdt_runtime::{ + CrdtRuntime, ProjectionFallbackStats, RuntimeAssetMetadata, RuntimeEvent, RuntimePresenceCaretRequest, RuntimePresenceCarets, + RuntimeRevisionInfo, SemanticCommand, +}; +use crate::presence::PresenceSelection; + +#[derive(Clone)] +pub struct CrdtRuntimeHandle { + commands: Sender, +} + +impl CrdtRuntimeHandle { + pub fn spawn(runtime: CrdtRuntime) -> io::Result { + let (commands, receiver) = async_channel::unbounded(); + thread::Builder::new() + .name("flowstate-crdt-runtime".to_string()) + .spawn(move || runtime_loop(runtime, receiver))?; + Ok(Self { commands }) + } +} + +impl CrdtRuntimeHandle { + pub async fn apply_editor_commands( + &self, + base_frontier: Vec, + commands: Vec, + assets: Vec, + selection_after: Option, + ) -> Result> { + self + .request(|reply| RuntimeRequest::ApplyEditorCommands { + base_frontier, + commands, + assets, + selection_after, + reply, + }) + .await + } + + pub async fn command(&self, command: SemanticCommand) -> Result> { + self + .request(|reply| RuntimeRequest::Command { command, reply }) + .await + } + + pub async fn import_remote_update(&self, bytes: Vec) -> Result> { + self + .request(|reply| RuntimeRequest::ImportRemoteUpdate { bytes, reply }) + .await + } + + pub async fn projection_snapshot(&self) -> Result { + self + .request(|reply| RuntimeRequest::ProjectionSnapshot { reply }) + .await + } + + pub async fn oplog_version_vector(&self) -> Result> { + self + .request(|reply| RuntimeRequest::OplogVersionVector { reply }) + .await + } + + pub async fn export_updates_for(&self, remote_vv: Vec) -> Result> { + self + .request(|reply| RuntimeRequest::ExportUpdatesFor { remote_vv, reply }) + .await + } + + pub async fn snapshot_bytes(&self) -> Result> { + self + .request(|reply| RuntimeRequest::SnapshotBytes { reply }) + .await + } + + pub async fn checkpoint_package(&self, title: String, path: Option) -> Result> { + self + .request(|reply| RuntimeRequest::CheckpointPackage { title, path, reply }) + .await + } + + pub async fn package_bytes(&self, title: String) -> Result> { + self + .request(|reply| RuntimeRequest::PackageBytes { title, reply }) + .await + } + + pub async fn save_package_to(&self, path: PathBuf) -> Result<()> { + self + .request(|reply| RuntimeRequest::SavePackageTo { path, reply }) + .await + } + + pub async fn take_restored_undo_selection(&self) -> Result> { + self + .request(|reply| RuntimeRequest::TakeRestoredUndoSelection { reply }) + .await + } + + pub async fn asset_metadata(&self) -> Result> { + self + .request(|reply| RuntimeRequest::AssetMetadata { reply }) + .await + } + + pub async fn revisions(&self) -> Result> { + self + .request(|reply| RuntimeRequest::Revisions { reply }) + .await + } + + pub async fn projection_fallback_stats(&self) -> Result { + self + .request(|reply| RuntimeRequest::ProjectionFallbackStats { reply }) + .await + } + + pub async fn presence_selection(&self, selection: EditorSelection) -> Result> { + self + .request(|reply| RuntimeRequest::PresenceSelection { selection, reply }) + .await + } + + pub async fn resolve_presence_carets(&self, requests: Vec) -> Result { + self + .request(|reply| RuntimeRequest::ResolvePresenceCarets { requests, reply }) + .await + } + + /// §15/§31: bind a stable durable author identity to the live runtime so later + /// revisions record this user as their author and `users_by_id` is populated. + pub async fn set_author_identity(&self, user_id: u128, display_name: Option) -> Result<()> { + self + .request(|reply| RuntimeRequest::SetAuthorIdentity { + user_id, + display_name, + reply, + }) + .await + } + + async fn request(&self, make: impl FnOnce(Sender>) -> RuntimeRequest) -> Result { + let (reply_tx, reply_rx) = async_channel::bounded(1); + self + .commands + .send(make(reply_tx)) + .await + .map_err(|_| anyhow!("Flowstate CRDT runtime actor stopped"))?; + reply_rx + .recv() + .await + .map_err(|_| anyhow!("Flowstate CRDT runtime actor dropped its response"))? + } +} + +enum RuntimeRequest { + ApplyEditorCommands { + base_frontier: Vec, + commands: Vec, + assets: Vec, + selection_after: Option, + reply: Sender>>, + }, + Command { + command: SemanticCommand, + reply: Sender>>, + }, + ImportRemoteUpdate { + bytes: Vec, + reply: Sender>>, + }, + ProjectionSnapshot { + reply: Sender>, + }, + OplogVersionVector { + reply: Sender>>, + }, + ExportUpdatesFor { + remote_vv: Vec, + reply: Sender>>, + }, + SnapshotBytes { + reply: Sender>>, + }, + CheckpointPackage { + title: String, + path: Option, + reply: Sender>>, + }, + PackageBytes { + title: String, + reply: Sender>>, + }, + SavePackageTo { + path: PathBuf, + reply: Sender>, + }, + TakeRestoredUndoSelection { + reply: Sender>>, + }, + AssetMetadata { + reply: Sender>>, + }, + Revisions { + reply: Sender>>, + }, + ProjectionFallbackStats { + reply: Sender>, + }, + PresenceSelection { + selection: EditorSelection, + reply: Sender>>, + }, + ResolvePresenceCarets { + requests: Vec, + reply: Sender>, + }, + SetAuthorIdentity { + user_id: u128, + display_name: Option, + reply: Sender>, + }, +} + +fn runtime_loop(mut runtime: CrdtRuntime, receiver: Receiver) { + let mut deferred = None; + loop { + let request = match deferred.take() { + Some(request) => request, + None => match receiver.recv_blocking() { + Ok(request) => request, + Err(_) => break, + }, + }; + match request { + RuntimeRequest::ApplyEditorCommands { + base_frontier, + mut commands, + mut assets, + mut selection_after, + reply, + } => { + let mut replies = vec![reply]; + while let Ok(next) = receiver.try_recv() { + match next { + RuntimeRequest::ApplyEditorCommands { + base_frontier: next_base_frontier, + commands: next_commands, + assets: next_assets, + selection_after: next_selection, + reply, + } if next_base_frontier == base_frontier => { + commands.extend(next_commands); + assets.extend(next_assets); + if next_selection.is_some() { + selection_after = next_selection; + } + replies.push(reply); + }, + other => { + deferred = Some(other); + break; + }, + } + } + let commands = coalesce_editor_commands(commands); + let result: Result> = (|| { + let mut events = runtime.apply_editor_commands(&base_frontier, &commands, selection_after.as_ref())?; + events.extend(runtime.merge_asset_records(assets)?); + Ok(events) + })(); + match result { + Ok(events) => { + let final_reply = replies.pop(); + for reply in replies { + send_reply(reply, Ok(Vec::new())); + } + if let Some(reply) = final_reply { + send_reply(reply, Ok(events)); + } + }, + Err(error) => { + let stale_projection = error + .downcast_ref::() + .copied(); + let message = error.to_string(); + for reply in replies { + let error = stale_projection + .map(anyhow::Error::new) + .unwrap_or_else(|| anyhow!(message.clone())); + send_reply(reply, Err(error)); + } + }, + } + }, + RuntimeRequest::Command { command, reply } => send_reply(reply, runtime.command(command)), + RuntimeRequest::ImportRemoteUpdate { bytes, reply } => send_reply(reply, runtime.import_remote_update(&bytes)), + RuntimeRequest::ProjectionSnapshot { reply } => send_reply(reply, runtime.projection_snapshot()), + RuntimeRequest::OplogVersionVector { reply } => send_reply(reply, Ok(runtime.doc().oplog_vv().encode())), + RuntimeRequest::ExportUpdatesFor { remote_vv, reply } => { + let result = VersionVector::decode(&remote_vv) + .context("decoding remote Loro version vector") + .and_then(|vv| runtime.export_updates_for(&vv)); + send_reply(reply, result); + }, + RuntimeRequest::SnapshotBytes { reply } => { + send_reply( + reply, + runtime + .doc() + .export(ExportMode::Snapshot) + .context("exporting Loro snapshot"), + ); + }, + RuntimeRequest::CheckpointPackage { title, path, reply } => { + send_reply(reply, runtime.checkpoint_package(&title, path).map_err(Into::into)); + }, + RuntimeRequest::PackageBytes { title, reply } => { + send_reply(reply, runtime.package_bytes(&title).map_err(Into::into)); + }, + RuntimeRequest::SavePackageTo { path, reply } => { + send_reply(reply, runtime.save_package_to(path).map_err(Into::into)); + }, + RuntimeRequest::TakeRestoredUndoSelection { reply } => { + send_reply(reply, Ok(runtime.take_restored_undo_selection())); + }, + RuntimeRequest::AssetMetadata { reply } => send_reply(reply, runtime.asset_metadata()), + RuntimeRequest::Revisions { reply } => send_reply(reply, Ok(runtime.revisions())), + RuntimeRequest::ProjectionFallbackStats { reply } => send_reply(reply, Ok(runtime.projection_fallback_stats())), + RuntimeRequest::PresenceSelection { selection, reply } => { + send_reply(reply, Ok(runtime.presence_selection(&selection))); + }, + RuntimeRequest::ResolvePresenceCarets { requests, reply } => { + send_reply(reply, Ok(runtime.resolve_presence_carets(requests))); + }, + RuntimeRequest::SetAuthorIdentity { + user_id, + display_name, + reply, + } => { + send_reply(reply, runtime.set_author_identity(user_id, display_name)); + }, + } + } +} + +fn coalesce_editor_commands(commands: Vec) -> Vec { + let mut coalesced = Vec::with_capacity(commands.len()); + for command in commands { + if let EditorSemanticCommand::DeleteRange { range } = &command + && let Some(EditorSemanticCommand::InsertText { at, text, .. }) = coalesced.last_mut() + && range.start.paragraph == at.paragraph + && range.end.paragraph == at.paragraph + && range.end.byte == at.byte.saturating_add(text.len()) + && range.start.byte >= at.byte + && range.start.byte <= range.end.byte + { + text.truncate(range.start.byte - at.byte); + if text.is_empty() { + coalesced.pop(); + } + continue; + } + if let EditorSemanticCommand::InsertText { at, text, styles } = &command + && let Some(EditorSemanticCommand::InsertText { + at: previous_at, + text: previous_text, + styles: previous_styles, + }) = coalesced.last_mut() + && previous_at.paragraph == at.paragraph + && *previous_styles == *styles + && previous_at.byte.saturating_add(previous_text.len()) == at.byte + { + previous_text.push_str(text); + continue; + } + coalesced.push(command); + } + coalesced +} + +fn send_reply(reply: Sender>, result: Result) { + let _ = reply.send_blocking(result); +} + +#[cfg(test)] +mod tests { + use super::*; + use flowstate_document::{DocumentOffset, RunStyles}; + + #[tokio::test] + async fn stale_projection_error_survives_actor_boundary() -> Result<()> { + let runtime = CrdtRuntime::new_empty("Actor stale frontier")?; + let handle = CrdtRuntimeHandle::spawn(runtime)?; + let base_frontier = handle.projection_snapshot().await?.frontier; + + handle + .command(SemanticCommand::InsertText { + unicode_index: 1, + text: "remote".to_string(), + styles: RunStyles::default(), + }) + .await?; + + let error = handle + .apply_editor_commands( + base_frontier, + vec![EditorSemanticCommand::InsertText { + at: DocumentOffset { paragraph: 0, byte: 0 }, + text: "local".to_string(), + styles: RunStyles::default(), + }], + Vec::new(), + None, + ) + .await + .expect_err("stale editor commands must be rejected"); + + assert!( + error + .downcast_ref::() + .is_some() + ); + Ok(()) + } + + #[tokio::test] + async fn set_author_identity_round_trips_through_actor() -> Result<()> { + let runtime = CrdtRuntime::new_empty("Actor author identity")?; + let handle = CrdtRuntimeHandle::spawn(runtime)?; + + handle + .set_author_identity(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef, Some("Author".to_string())) + .await?; + + // The runtime stays usable after binding the durable author identity. + handle.projection_snapshot().await?; + Ok(()) + } +} diff --git a/crates/flowstate-collab/src/ids.rs b/crates/flowstate-collab/src/ids.rs new file mode 100644 index 00000000..10209474 --- /dev/null +++ b/crates/flowstate-collab/src/ids.rs @@ -0,0 +1,120 @@ +use std::{fmt, str::FromStr}; + +use rand::{TryRngCore as _, rngs::OsRng}; +use serde::{Deserialize, Serialize}; + +pub type PeerId = iroh::EndpointId; + +pub const SESSION_ID_LEN: usize = 32; + +pub const PALETTE: [u32; 8] = [0x3b82f6, 0xef4444, 0x22c55e, 0xf59e0b, 0x8b5cf6, 0x06b6d4, 0xec4899, 0x84cc16]; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct SessionId([u8; SESSION_ID_LEN]); + +impl SessionId { + #[must_use] + pub fn new() -> Self { + let mut bytes = [0; SESSION_ID_LEN]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("OS randomness must be available to create collaboration session IDs"); + Self(bytes) + } + + #[must_use] + pub const fn from_bytes(bytes: [u8; SESSION_ID_LEN]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; SESSION_ID_LEN] { + &self.0 + } + + #[must_use] + pub fn color_index_for_peer(peer: &PeerId) -> usize { + Self::color_index_for_peer_bytes(peer.as_bytes()) + } + + #[must_use] + pub fn color_index_for_peer_bytes(bytes: &[u8]) -> usize { + let mut hash = 0usize; + for byte in bytes.iter().take(8) { + hash = hash.wrapping_mul(257).wrapping_add(usize::from(*byte)); + } + hash % PALETTE.len() + } +} + +impl Default for SessionId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for SessionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl FromStr for SessionId { + type Err = SessionIdParseError; + + fn from_str(value: &str) -> Result { + if value.len() != SESSION_ID_LEN * 2 { + return Err(SessionIdParseError); + } + + let mut bytes = [0; SESSION_ID_LEN]; + for (ix, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let text = std::str::from_utf8(chunk).map_err(|_| SessionIdParseError)?; + bytes[ix] = u8::from_str_radix(text, 16).map_err(|_| SessionIdParseError)?; + } + Ok(Self(bytes)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SessionIdParseError; + +impl fmt::Display for SessionIdParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("expected a 64-character hex session id") + } +} + +#[cfg(test)] +mod tests { + use super::SessionId; + + #[test] + fn parses_uppercase_session_id_hex() { + let parsed = "0123456789ABCDEFFEDCBA98765432100123456789ABCDEFFEDCBA9876543210" + .parse::() + .expect("uppercase hex should parse"); + assert_eq!(parsed.to_string(), "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"); + } +} + +impl std::error::Error for SessionIdParseError {} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct BlobId(pub u128); + +impl BlobId { + #[must_use] + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().as_u128()) + } +} + +impl Default for BlobId { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/flowstate-collab/src/lib.rs b/crates/flowstate-collab/src/lib.rs new file mode 100644 index 00000000..69dc18d9 --- /dev/null +++ b/crates/flowstate-collab/src/lib.rs @@ -0,0 +1,19 @@ +//! Peer-to-peer collaboration support for Flowstate rich-text documents. +//! +//! This crate contains the GPUI-free collaboration core: the Loro-native CRDT +//! runtime, transport protocol types, presence, and networking state. +//! Application/UI integration lives in `crates/flowstate/src/collab`. + +pub mod crdt_runtime; +pub mod crdt_runtime_actor; +pub mod ids; +pub mod net; +pub mod presence; +pub mod proto_direct; +pub mod proto_gossip; +pub mod self_check; +pub mod ticket; + +pub use ids::{BlobId, SessionId}; +pub use proto_direct::DIRECT_ALPN; +pub use proto_gossip::PROTOCOL_VERSION; diff --git a/crates/flowstate-collab/src/net/anti_entropy.rs b/crates/flowstate-collab/src/net/anti_entropy.rs new file mode 100644 index 00000000..6f9b7db7 --- /dev/null +++ b/crates/flowstate-collab/src/net/anti_entropy.rs @@ -0,0 +1,147 @@ +use std::time::{Duration, Instant}; + +use iroh::EndpointId; + +use crate::ids::SessionId; + +pub const DIGEST_INTERVAL: Duration = Duration::from_secs(10); +pub const DIGEST_JITTER_PERCENT: u32 = 20; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VersionVectorRelation { + Equal, + SenderHasMissingOps, + WeHaveMissingOps, + Concurrent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GapAction { + None, + Pull { from: EndpointId, our_vv: Vec }, + LineageMismatch { from: EndpointId, expected: SessionId, got: SessionId }, +} + +#[derive(Clone, Debug)] +pub struct AntiEntropyState { + session: SessionId, + next_digest: Instant, + pull_in_flight: bool, +} + +impl AntiEntropyState { + #[must_use] + pub fn new(session: SessionId, now: Instant) -> Self { + Self { + session, + next_digest: next_jittered_deadline(now), + pull_in_flight: false, + } + } + + #[must_use] + pub const fn session(&self) -> SessionId { + self.session + } + + #[must_use] + pub fn digest_due(&self, now: Instant) -> bool { + now >= self.next_digest + } + + #[must_use] + pub fn duration_until_digest(&self, now: Instant) -> Duration { + self.next_digest.saturating_duration_since(now) + } + + pub fn mark_digest_sent(&mut self, now: Instant) { + self.next_digest = next_jittered_deadline(now); + tracing::trace!(session = %self.session, next_digest_in = ?self.next_digest.saturating_duration_since(now), "collaboration anti-entropy digest sent"); + } + + pub fn schedule_immediate_digest(&mut self) { + self.next_digest = Instant::now(); + tracing::trace!(session = %self.session, "collaboration anti-entropy digest scheduled immediately"); + } + + pub fn on_neighbor_up(&mut self) { + tracing::debug!(session = %self.session, "collaboration anti-entropy neighbor-up recovery triggered"); + self.schedule_immediate_digest(); + } + + pub fn on_reconnect(&mut self) { + tracing::debug!(session = %self.session, "collaboration anti-entropy reconnect recovery triggered"); + self.schedule_immediate_digest(); + } + + #[must_use] + pub fn on_lagged(&mut self, fallback_peer: Option, our_vv: Vec) -> GapAction { + tracing::warn!(session = %self.session, fallback_peer = ?fallback_peer, our_vv_bytes = our_vv.len(), "collaboration anti-entropy gossip lagged"); + self.schedule_immediate_digest(); + let Some(from) = fallback_peer else { + tracing::warn!(session = %self.session, "collaboration anti-entropy lagged without fallback peer"); + return GapAction::None; + }; + self.begin_pull(from, our_vv) + } + + #[must_use] + pub fn consider_digest(&mut self, from: EndpointId, digest_session: SessionId, relation: VersionVectorRelation, our_vv: Vec) -> GapAction { + tracing::trace!( + session = %self.session, + from = %from, + digest_session = %digest_session, + ?relation, + our_vv_bytes = our_vv.len(), + "collaboration anti-entropy digest considered", + ); + if digest_session != self.session { + tracing::warn!( + session = %self.session, + from = %from, + digest_session = %digest_session, + "collaboration anti-entropy digest lineage mismatch", + ); + return GapAction::LineageMismatch { + from, + expected: self.session, + got: digest_session, + }; + } + + match relation { + VersionVectorRelation::SenderHasMissingOps | VersionVectorRelation::Concurrent => self.begin_pull(from, our_vv), + VersionVectorRelation::Equal | VersionVectorRelation::WeHaveMissingOps => { + tracing::trace!(session = %self.session, from = %from, ?relation, "collaboration anti-entropy digest needs no pull"); + GapAction::None + }, + } + } + + pub fn finish_pull(&mut self) { + tracing::debug!(session = %self.session, was_in_flight = self.pull_in_flight, "collaboration anti-entropy pull finished"); + self.pull_in_flight = false; + } + + fn begin_pull(&mut self, from: EndpointId, our_vv: Vec) -> GapAction { + if self.pull_in_flight { + tracing::debug!(session = %self.session, from = %from, "collaboration anti-entropy skipped pull because one is already in flight"); + return GapAction::None; + } + self.pull_in_flight = true; + tracing::debug!(session = %self.session, from = %from, our_vv_bytes = our_vv.len(), "collaboration anti-entropy pull started"); + GapAction::Pull { from, our_vv } + } +} + +#[must_use] +pub fn next_jittered_deadline(now: Instant) -> Instant { + let base_millis = DIGEST_INTERVAL.as_millis() as u64; + let jitter_millis = base_millis * u64::from(DIGEST_JITTER_PERCENT) / 100; + let spread = jitter_millis.saturating_mul(2).saturating_add(1); + let offset = rand::random::() % spread; + let delay_millis = base_millis + .saturating_add(offset) + .saturating_sub(jitter_millis); + now + Duration::from_millis(delay_millis) +} diff --git a/crates/flowstate-collab/src/net/blobs.rs b/crates/flowstate-collab/src/net/blobs.rs new file mode 100644 index 00000000..2206efef --- /dev/null +++ b/crates/flowstate-collab/src/net/blobs.rs @@ -0,0 +1,117 @@ +use std::{collections::VecDeque, num::NonZeroUsize}; + +use crate::ids::BlobId; + +pub const DEFAULT_MAX_BLOBS: usize = 64; +pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug)] +pub struct BlobOutbox { + max_blobs: NonZeroUsize, + max_bytes: usize, + total_bytes: usize, + entries: VecDeque<(BlobId, Vec)>, +} + +impl Default for BlobOutbox { + fn default() -> Self { + Self { + max_blobs: NonZeroUsize::new(DEFAULT_MAX_BLOBS).expect("constant is non-zero"), + max_bytes: DEFAULT_MAX_BYTES, + total_bytes: 0, + entries: VecDeque::new(), + } + } +} + +impl BlobOutbox { + #[must_use] + pub fn new(max_blobs: NonZeroUsize, max_bytes: usize) -> Self { + Self { + max_blobs, + max_bytes, + total_bytes: 0, + entries: VecDeque::new(), + } + } + + pub fn insert(&mut self, bytes: Vec) -> Option { + let id = BlobId::new(); + self.insert_with_id(id, bytes).then_some(id) + } + + pub fn insert_with_id(&mut self, id: BlobId, bytes: Vec) -> bool { + if bytes.len() > self.max_bytes { + return false; + } + + if let Some((_, existing)) = self + .entries + .iter_mut() + .find(|(candidate, _)| *candidate == id) + { + self.total_bytes = self + .total_bytes + .saturating_sub(existing.len()) + .saturating_add(bytes.len()); + *existing = bytes; + self.trim(); + return true; + } + + self.total_bytes = self.total_bytes.saturating_add(bytes.len()); + self.entries.push_back((id, bytes)); + self.trim(); + true + } + + #[must_use] + pub fn get(&self, id: BlobId) -> Option<&[u8]> { + self + .entries + .iter() + .find_map(|(candidate, bytes)| (*candidate == id).then_some(bytes.as_slice())) + } + + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + #[must_use] + pub const fn total_bytes(&self) -> usize { + self.total_bytes + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + fn trim(&mut self) { + while self.entries.len() > self.max_blobs.get() || self.total_bytes > self.max_bytes { + let Some((_, bytes)) = self.entries.pop_front() else { + self.total_bytes = 0; + return; + }; + self.total_bytes = self.total_bytes.saturating_sub(bytes.len()); + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use crate::{BlobId, net::blobs::BlobOutbox}; + #[test] + fn insert_with_id_rejects_oversized_payloads_before_insertion() { + let mut outbox = BlobOutbox::new(NonZeroUsize::new(4).expect("non-zero max blobs"), 2); + let id = BlobId(7); + + assert!(!outbox.insert_with_id(id, vec![1, 2, 3])); + assert!(outbox.is_empty()); + assert_eq!(outbox.total_bytes(), 0); + assert_eq!(outbox.get(id), None); + } +} diff --git a/crates/flowstate-collab/src/net/direct.rs b/crates/flowstate-collab/src/net/direct.rs new file mode 100644 index 00000000..1214fdc6 --- /dev/null +++ b/crates/flowstate-collab/src/net/direct.rs @@ -0,0 +1,522 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, OnceLock, RwLock as StdRwLock}, + time::Duration, +}; + +use anyhow::{Context as _, Result, anyhow, ensure}; +use async_channel::Sender; +use iroh::{ + Endpoint, EndpointId, + endpoint::{Connection, RecvStream, SendStream}, + protocol::{AcceptError, ProtocolHandler}, +}; +use tokio::{ + sync::{OwnedSemaphorePermit, RwLock, Semaphore}, + time::timeout, +}; + +use crate::{ + ids::{BlobId, SessionId}, + proto_direct::{ + AssetBytes, DIRECT_ALPN, DirectRequest, DirectResponseHeader, MAX_FRAME_LEN, MAX_PAYLOAD_CHUNK_LEN, decode_frame, encode_frame, + }, +}; + +use super::{PullProgress, blobs::BlobOutbox}; + +const DIRECT_SERVE_CONCURRENCY: usize = 4; +const DIRECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); +static CLIENT_ENDPOINT: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Debug)] +pub struct DirectSessionHandler { + requests: Sender, +} + +impl DirectSessionHandler { + #[must_use] + pub fn new(requests: Sender) -> Self { + Self { requests } + } +} + +#[derive(Debug)] +pub enum DirectServeRequest { + Snapshot { reply: Sender>> }, + Updates { have_vv: Vec, reply: Sender>> }, + Asset { asset: u128, reply: Sender> }, +} + +#[derive(Clone, Debug, Default)] +pub struct DirectServeState { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct DirectServeInner { + attached: HashSet, + blobs: HashMap, + handlers: HashMap, +} + +impl DirectServeState { + pub async fn attach_session(&self, session: SessionId) { + let mut inner = self.inner.write().await; + let inserted = inner.attached.insert(session); + tracing::debug!(%session, inserted, attached_sessions = inner.attached.len(), "attached collaboration direct session"); + } + + pub async fn detach_session(&self, session: SessionId) { + let mut inner = self.inner.write().await; + let removed = inner.attached.remove(&session); + let blob_count = inner + .blobs + .remove(&session) + .map_or(0, |outbox| outbox.len()); + let handler_removed = inner.handlers.remove(&session).is_some(); + tracing::debug!(%session, removed, blob_count, handler_removed, attached_sessions = inner.attached.len(), "detached collaboration direct session"); + } + + pub async fn register_handler(&self, session: SessionId, handler: DirectSessionHandler) { + let mut inner = self.inner.write().await; + inner.attached.insert(session); + let replaced = inner.handlers.insert(session, handler).is_some(); + tracing::debug!(%session, replaced, handler_count = inner.handlers.len(), "registered collaboration direct session handler"); + } + + #[allow( + clippy::significant_drop_tightening, + reason = "write guard scope is intentionally tight and no await occurs while held" + )] + pub async fn insert_blob(&self, session: SessionId, bytes: Vec) -> Result { + let byte_len = bytes.len(); + let (blob, outbox_len, outbox_bytes) = { + let mut inner = self.inner.write().await; + inner.attached.insert(session); + let outbox = inner.blobs.entry(session).or_default(); + let blob = BlobId::new(); + ensure!(outbox.insert_with_id(blob, bytes), "collaboration direct blob exceeds outbox capacity"); + let outbox_len = outbox.len(); + let outbox_bytes = outbox.total_bytes(); + (blob, outbox_len, outbox_bytes) + }; + tracing::debug!( + %session, + ?blob, + byte_len, + outbox_len, + outbox_bytes, + "stored collaboration direct blob for peer pull", + ); + Ok(blob) + } + + async fn serve(&self, request: DirectRequest) -> ServeOutcome { + let session = request.session(); + let request_kind = direct_request_kind(&request); + let request_detail_bytes = direct_request_detail_bytes(&request); + tracing::trace!(%session, request_kind, request_detail_bytes, "serving collaboration direct request"); + let handler = { + let inner = self.inner.read().await; + if !inner.attached.contains(&session) { + tracing::warn!(%session, request_kind, "collaboration direct request rejected because session is not attached"); + return ServeOutcome::Header(DirectResponseHeader::NotAttached); + } + if let DirectRequest::Blob { blob, .. } = request { + return inner + .blobs + .get(&session) + .and_then(|outbox| outbox.get(blob)) + .map_or_else( + || { + tracing::warn!(%session, ?blob, "collaboration direct blob request missed outbox"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + |bytes| { + tracing::debug!(%session, ?blob, bytes = bytes.len(), "collaboration direct blob request hit outbox"); + ServeOutcome::Payload(bytes.to_vec()) + }, + ); + } + inner.handlers.get(&session).cloned() + }; + + let Some(handler) = handler else { + tracing::warn!(%session, request_kind, "collaboration direct request rejected because handler is missing"); + return ServeOutcome::Header(DirectResponseHeader::NotFound); + }; + + match request { + DirectRequest::Snapshot { .. } => { + request_payload(handler.requests, session, request_kind, |reply| DirectServeRequest::Snapshot { reply }).await + }, + DirectRequest::Updates { have_vv, .. } => { + tracing::trace!(%session, have_vv_bytes = have_vv.len(), "forwarding collaboration direct updates request to session"); + request_payload(handler.requests, session, request_kind, |reply| DirectServeRequest::Updates { + have_vv, + reply, + }) + .await + }, + DirectRequest::Asset { asset, .. } => request_asset(handler.requests, session, asset).await, + DirectRequest::Blob { .. } => ServeOutcome::Header(DirectResponseHeader::NotFound), + } + } +} + +#[derive(Debug)] +enum ServeOutcome { + Header(DirectResponseHeader), + Payload(Vec), +} + +impl ServeOutcome { + fn kind(&self) -> &'static str { + match self { + Self::Header(header) => direct_response_header_kind(header), + Self::Payload(_) => "ok", + } + } + + fn payload_len(&self) -> usize { + match self { + Self::Header(_) => 0, + Self::Payload(payload) => payload.len(), + } + } +} + +#[derive(Clone, Debug)] +pub struct DirectProto { + state: DirectServeState, + permits: Arc, +} + +impl DirectProto { + #[must_use] + pub fn new(state: DirectServeState) -> Self { + Self { + state, + permits: Arc::new(Semaphore::new(DIRECT_SERVE_CONCURRENCY)), + } + } + + async fn handle_stream(&self, mut send: SendStream, mut recv: RecvStream, _permit: OwnedSemaphorePermit) -> Result<()> { + let request = read_frame::(&mut recv).await?; + let session = request.session(); + let request_kind = direct_request_kind(&request); + tracing::debug!(%session, request_kind, "received collaboration direct stream request"); + let outcome = self.state.serve(request).await; + tracing::debug!(%session, request_kind, outcome = outcome.kind(), payload_bytes = outcome.payload_len(), "collaboration direct request served"); + write_response(&mut send, outcome).await?; + Ok(()) + } +} + +impl ProtocolHandler for DirectProto { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + tracing::trace!("accepted collaboration direct connection"); + while let Ok((send, recv)) = connection.accept_bi().await { + let Ok(permit) = self.permits.clone().try_acquire_owned() else { + let mut send = send; + if let Err(error) = write_response(&mut send, ServeOutcome::Header(DirectResponseHeader::Busy)).await { + tracing::warn!("flowstate collab direct busy response failed: {error:#}"); + } + continue; + }; + let proto = self.clone(); + tokio::spawn(async move { + if let Err(error) = proto.handle_stream(send, recv, permit).await { + tracing::error!(error = %format_args!("{error:#}"), "collaboration direct stream failed"); + } + }); + } + tracing::trace!("collaboration direct connection closed"); + Ok(()) + } +} + +pub(crate) fn install_endpoint(endpoint: Endpoint) { + let cache = CLIENT_ENDPOINT.get_or_init(|| StdRwLock::new(None)); + if let Ok(mut cached) = cache.write() { + *cached = Some(endpoint); + tracing::debug!(installed = true, "installed collaboration direct client endpoint"); + } else { + tracing::warn!("failed to install collaboration direct client endpoint"); + } +} + +pub async fn pull_with_fallback(req: DirectRequest, candidates: Vec, per_peer_timeout: Duration) -> Result> { + pull_with_fallback_progress(req, candidates, per_peer_timeout, None).await +} + +pub async fn pull_with_fallback_progress( + req: DirectRequest, + candidates: Vec, + per_peer_timeout: Duration, + progress: Option>, +) -> Result> { + let endpoint = CLIENT_ENDPOINT + .get() + .and_then(|cache| cache.read().ok().and_then(|cached| cached.clone())) + .ok_or_else(|| anyhow!("collaboration direct client endpoint is not running"))?; + ensure!(!endpoint.is_closed(), "collaboration direct client endpoint is not running"); + pull_with_endpoint_progress(&endpoint, req, candidates, per_peer_timeout, progress).await +} + +pub async fn pull_with_endpoint( + endpoint: &Endpoint, + req: DirectRequest, + candidates: Vec, + per_peer_timeout: Duration, +) -> Result> { + pull_with_endpoint_progress(endpoint, req, candidates, per_peer_timeout, None).await +} + +pub async fn pull_with_endpoint_progress( + endpoint: &Endpoint, + req: DirectRequest, + candidates: Vec, + per_peer_timeout: Duration, + progress: Option>, +) -> Result> { + let session = req.session(); + let request_kind = direct_request_kind(&req); + let candidate_count = candidates.len(); + if candidates.is_empty() { + tracing::warn!(%session, request_kind, "collaboration direct pull has no candidate peers"); + } + ensure!(!candidates.is_empty(), "direct pull has no candidate peers"); + tracing::debug!(%session, request_kind, candidate_count, ?per_peer_timeout, "starting collaboration direct pull"); + + let mut errors = Vec::new(); + for peer in candidates { + tracing::trace!(%session, request_kind, peer = %peer, "attempting collaboration direct pull peer"); + match timeout(per_peer_timeout, pull_once(endpoint, peer, req.clone(), progress.as_ref())).await { + Ok(Ok(bytes)) => { + tracing::debug!(%session, request_kind, peer = %peer, bytes = bytes.len(), "collaboration direct pull peer succeeded"); + return Ok(bytes); + }, + Ok(Err(error)) => { + tracing::warn!(%session, request_kind, peer = %peer, error = %format_args!("{error:#}"), "collaboration direct pull peer failed"); + errors.push(format!("{peer}: {error:#}")); + }, + Err(_) => { + tracing::warn!(%session, request_kind, peer = %peer, ?per_peer_timeout, "collaboration direct pull peer timed out"); + errors.push(format!("{peer}: timed out after {per_peer_timeout:?}")); + }, + } + } + + tracing::error!(%session, request_kind, candidate_count, "collaboration direct pull failed for all candidates"); + Err(anyhow!("direct pull failed for all candidates: {}", errors.join("; "))) +} + +async fn pull_once(endpoint: &Endpoint, peer: EndpointId, req: DirectRequest, progress: Option<&Sender>) -> Result> { + let session = req.session(); + let request_kind = direct_request_kind(&req); + tracing::trace!(%session, request_kind, peer = %peer, "dialing collaboration direct peer"); + let connection = endpoint + .connect(peer, DIRECT_ALPN) + .await + .context("direct dial failed")?; + tracing::trace!(%session, request_kind, peer = %peer, "opening collaboration direct stream"); + let (mut send, mut recv) = connection + .open_bi() + .await + .context("opening direct request stream failed")?; + let frame = encode_frame(&req)?; + tracing::trace!(%session, request_kind, peer = %peer, frame_bytes = frame.len(), "sending collaboration direct request frame"); + write_frame(&mut send, &frame).await?; + send.finish()?; + + let header = read_frame::(&mut recv).await?; + tracing::trace!(%session, request_kind, peer = %peer, response = direct_response_header_kind(&header), "received collaboration direct response header"); + match header { + DirectResponseHeader::Ok { total_len } => { + let payload = read_payload(&mut recv, total_len, progress).await?; + tracing::trace!(%session, request_kind, peer = %peer, bytes = payload.len(), "read collaboration direct response payload"); + Ok(payload) + }, + DirectResponseHeader::NotAttached => Err(anyhow!("peer is not attached to this session")), + DirectResponseHeader::NotFound => Err(anyhow!("peer does not have the requested collaboration data")), + DirectResponseHeader::Busy => Err(anyhow!("peer is busy serving collaboration data")), + } +} + +async fn request_payload( + requests: Sender, + session: SessionId, + request_kind: &'static str, + make_request: F, +) -> ServeOutcome +where + F: FnOnce(Sender>>) -> DirectServeRequest, +{ + let (reply_tx, reply_rx) = async_channel::bounded(1); + if requests.send(make_request(reply_tx)).await.is_err() { + tracing::warn!(%session, request_kind, "collaboration direct session request channel closed"); + return ServeOutcome::Header(DirectResponseHeader::NotAttached); + } + match timeout(DIRECT_RESPONSE_TIMEOUT, reply_rx.recv()).await { + Ok(Ok(Ok(bytes))) => { + tracing::trace!(%session, request_kind, bytes = bytes.len(), "collaboration direct session returned payload"); + ServeOutcome::Payload(bytes) + }, + Ok(Ok(Err(error))) => { + tracing::warn!(%session, request_kind, error = %format_args!("{error:#}"), "collaboration direct session failed to produce payload"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + Ok(Err(error)) => { + tracing::warn!(%session, request_kind, error = %error, "collaboration direct session payload reply channel closed"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + Err(_) => { + tracing::warn!(%session, request_kind, ?DIRECT_RESPONSE_TIMEOUT, "collaboration direct session payload timed out"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + } +} + +async fn request_asset(requests: Sender, session: SessionId, asset: u128) -> ServeOutcome { + let (reply_tx, reply_rx) = async_channel::bounded(1); + if requests + .send(DirectServeRequest::Asset { asset, reply: reply_tx }) + .await + .is_err() + { + tracing::warn!(%session, asset, "collaboration direct asset request channel closed"); + return ServeOutcome::Header(DirectResponseHeader::NotAttached); + } + match timeout(DIRECT_RESPONSE_TIMEOUT, reply_rx.recv()).await { + Ok(Ok(Ok(asset_bytes))) => { + tracing::trace!(%session, asset, bytes = asset_bytes.bytes.len(), "collaboration direct session returned asset"); + ServeOutcome::Payload(asset_bytes.bytes) + }, + Ok(Ok(Err(error))) => { + tracing::warn!(%session, asset, error = %format_args!("{error:#}"), "collaboration direct session failed to produce asset"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + Ok(Err(error)) => { + tracing::warn!(%session, asset, error = %error, "collaboration direct asset reply channel closed"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + Err(_) => { + tracing::warn!(%session, asset, ?DIRECT_RESPONSE_TIMEOUT, "collaboration direct asset timed out"); + ServeOutcome::Header(DirectResponseHeader::NotFound) + }, + } +} + +async fn read_frame(recv: &mut RecvStream) -> Result +where + T: for<'de> serde::Deserialize<'de>, +{ + let mut len_bytes = [0; 4]; + recv.read_exact(&mut len_bytes).await?; + let payload_len = u32::from_le_bytes(len_bytes) as usize; + ensure!(payload_len <= MAX_FRAME_LEN, "direct frame exceeds {MAX_FRAME_LEN} bytes"); + let mut frame = len_bytes.to_vec(); + frame.resize(4 + payload_len, 0); + recv.read_exact(&mut frame[4..]).await?; + decode_frame(&frame) +} + +async fn write_response(send: &mut SendStream, outcome: ServeOutcome) -> Result<()> { + tracing::trace!( + outcome = outcome.kind(), + payload_bytes = outcome.payload_len(), + "writing collaboration direct response" + ); + match outcome { + ServeOutcome::Header(header) => write_frame(send, &encode_frame(&header)?).await?, + ServeOutcome::Payload(payload) => { + let header = DirectResponseHeader::Ok { + total_len: payload.len() as u64, + }; + write_frame(send, &encode_frame(&header)?).await?; + write_payload(send, &payload).await?; + }, + } + send.finish()?; + Ok(()) +} + +async fn write_frame(send: &mut SendStream, frame: &[u8]) -> Result<()> { + tracing::trace!(frame_bytes = frame.len(), "writing collaboration direct frame"); + send.write_all(frame).await?; + Ok(()) +} + +async fn write_payload(send: &mut SendStream, payload: &[u8]) -> Result<()> { + tracing::trace!( + payload_bytes = payload.len(), + chunk_bytes = MAX_PAYLOAD_CHUNK_LEN, + "writing collaboration direct payload" + ); + for chunk in payload.chunks(MAX_PAYLOAD_CHUNK_LEN) { + send.write_all(chunk).await?; + } + Ok(()) +} + +async fn read_payload(recv: &mut RecvStream, total_len: u64, progress: Option<&Sender>) -> Result> { + let total_len_usize = usize::try_from(total_len).context("direct payload is too large for this platform")?; + ensure!(total_len_usize <= MAX_FRAME_LEN, "direct payload exceeds {MAX_FRAME_LEN} bytes"); + tracing::trace!( + payload_bytes = total_len_usize, + chunk_bytes = MAX_PAYLOAD_CHUNK_LEN, + "reading collaboration direct payload" + ); + let mut payload = vec![0; total_len_usize]; + let mut offset = 0; + if let Some(progress) = progress { + let _ = progress.try_send(PullProgress { got: 0, total: total_len }); + } + while offset < total_len_usize { + let next = (offset + MAX_PAYLOAD_CHUNK_LEN).min(total_len_usize); + recv.read_exact(&mut payload[offset..next]).await?; + offset = next; + if let Some(progress) = progress { + let _ = progress.try_send(PullProgress { + got: offset as u64, + total: total_len, + }); + } + } + Ok(payload) +} + +fn direct_request_kind(request: &DirectRequest) -> &'static str { + match request { + DirectRequest::Snapshot { .. } => "snapshot", + DirectRequest::Updates { .. } => "updates", + DirectRequest::Blob { .. } => "blob", + DirectRequest::Asset { .. } => "asset", + } +} + +fn direct_request_detail_bytes(request: &DirectRequest) -> usize { + match request { + DirectRequest::Updates { have_vv, .. } => have_vv.len(), + DirectRequest::Snapshot { .. } | DirectRequest::Blob { .. } | DirectRequest::Asset { .. } => 0, + } +} + +fn direct_response_header_kind(header: &DirectResponseHeader) -> &'static str { + match header { + DirectResponseHeader::Ok { .. } => "ok", + DirectResponseHeader::NotAttached => "not_attached", + DirectResponseHeader::NotFound => "not_found", + DirectResponseHeader::Busy => "busy", + } +} + +impl DirectRequest { + fn session(&self) -> SessionId { + match self { + Self::Snapshot { session } | Self::Updates { session, .. } | Self::Blob { session, .. } | Self::Asset { session, .. } => *session, + } + } +} diff --git a/crates/flowstate-collab/src/net/mod.rs b/crates/flowstate-collab/src/net/mod.rs new file mode 100644 index 00000000..68f03173 --- /dev/null +++ b/crates/flowstate-collab/src/net/mod.rs @@ -0,0 +1,140 @@ +pub mod anti_entropy; +pub mod blobs; +pub mod direct; +pub mod runtime; +pub mod swarm; + +use anyhow::Result; +use async_channel::Sender; +use iroh::{EndpointAddr, EndpointId}; + +use crate::{ + ids::{BlobId, SessionId}, + proto_direct::AssetBytes, + proto_gossip::GossipMsg, +}; + +use self::direct::DirectSessionHandler; + +pub type Reply = Sender; +pub type PeerAddr = EndpointAddr; + +#[derive(Clone, Debug)] +pub enum PublishPayload { + Update(Vec), + Presence(Vec), + Digest { vv: Vec }, +} + +impl PublishPayload { + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Update(_) => "update", + Self::Presence(_) => "presence", + Self::Digest { .. } => "digest", + } + } + + #[must_use] + pub fn byte_len(&self) -> usize { + match self { + Self::Update(bytes) | Self::Presence(bytes) => bytes.len(), + Self::Digest { vv } => vv.len(), + } + } +} + +#[derive(Debug)] +pub enum NetCommand { + EnsureUp, + RegisterDirectHandler { + session: SessionId, + handler: DirectSessionHandler, + }, + CreateSession { + session: SessionId, + reply: Reply>, + }, + JoinSession { + session: SessionId, + bootstrap: Vec, + }, + LeaveSession { + session: SessionId, + }, + Publish { + session: SessionId, + payload: PublishPayload, + }, + PullUpdates { + session: SessionId, + candidates: Vec, + our_vv: Vec, + reply: Reply>>, + }, + PullSnapshot { + session: SessionId, + candidates: Vec, + progress: Option>, + reply: Reply>>, + }, + PullBlob { + session: SessionId, + candidates: Vec, + blob: BlobId, + reply: Reply>>, + }, + PullAsset { + session: SessionId, + candidates: Vec, + asset: u128, + reply: Reply>, + }, + MintTicketAddr { + reply: Reply, + }, + Shutdown, +} + +impl NetCommand { + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::EnsureUp => "ensure_up", + Self::RegisterDirectHandler { .. } => "register_direct_handler", + Self::CreateSession { .. } => "create_session", + Self::JoinSession { .. } => "join_session", + Self::LeaveSession { .. } => "leave_session", + Self::Publish { .. } => "publish", + Self::PullUpdates { .. } => "pull_updates", + Self::PullSnapshot { .. } => "pull_snapshot", + Self::PullBlob { .. } => "pull_blob", + Self::PullAsset { .. } => "pull_asset", + Self::MintTicketAddr { .. } => "mint_ticket_addr", + Self::Shutdown => "shutdown", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct PullProgress { + pub got: u64, + pub total: u64, +} + +#[derive(Clone, Debug)] +pub struct TicketSeed { + pub inviter: EndpointAddr, +} + +#[derive(Clone, Debug)] +pub enum NetEvent { + Gossip { session: SessionId, from: EndpointId, msg: GossipMsg }, + IncompatibleVersion { session: SessionId, peer: EndpointId }, + NeighborUp { session: SessionId, peer: EndpointId }, + NeighborDown { session: SessionId, peer: EndpointId }, + GossipLagged { session: SessionId }, + SubscribeFailed { session: SessionId, error: String }, + EndpointOnline(bool), +} diff --git a/crates/flowstate-collab/src/net/runtime.rs b/crates/flowstate-collab/src/net/runtime.rs new file mode 100644 index 00000000..48389654 --- /dev/null +++ b/crates/flowstate-collab/src/net/runtime.rs @@ -0,0 +1,331 @@ +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, + thread, + time::Duration, +}; + +use anyhow::{Context as _, Result}; +use iroh::{Endpoint, EndpointAddr, Watcher as _, endpoint::presets}; +use iroh_gossip::net::Gossip; +use tokio::time::timeout; + +use crate::{ + SessionId, + proto_direct::{AssetBytes, DirectRequest}, +}; + +use super::{ + NetCommand, NetEvent, TicketSeed, + direct::{self, DirectProto, DirectServeState}, + swarm::SwarmHandle, +}; + +pub type CommandSender = async_channel::Sender; +pub type EventReceiver = async_channel::Receiver; + +const DIRECT_PULL_TIMEOUT: Duration = Duration::from_secs(10); +const ENDPOINT_ADDR_READY_TIMEOUT: Duration = Duration::from_secs(2); +static RUNTIME: OnceLock>> = OnceLock::new(); + +#[derive(Clone)] +struct RuntimeBridge { + commands: CommandSender, + events: EventReceiver, +} + +impl RuntimeBridge { + fn is_alive(&self) -> bool { + !self.commands.is_closed() + } + + fn handles(&self) -> (CommandSender, EventReceiver) { + (self.commands.clone(), self.events.clone()) + } +} + +pub fn start() -> Result<(CommandSender, EventReceiver)> { + let runtime = RUNTIME.get_or_init(|| Mutex::new(None)); + let mut runtime = runtime.lock().expect("collaboration runtime lock poisoned"); + if let Some(runtime) = runtime.as_ref().filter(|runtime| runtime.is_alive()) { + tracing::debug!("reusing existing collaboration network runtime"); + return Ok(runtime.handles()); + } + + tracing::info!("starting collaboration network runtime"); + let next_runtime = spawn_runtime()?; + let handles = next_runtime.handles(); + *runtime = Some(next_runtime); + drop(runtime); + Ok(handles) +} + +fn spawn_runtime() -> Result { + let (cmd_tx, cmd_rx) = async_channel::unbounded(); + let (evt_tx, evt_rx) = async_channel::unbounded(); + let thread_evt_tx = evt_tx.clone(); + + thread::Builder::new() + .name("flowstate-collab-net".into()) + .spawn(move || { + tracing::debug!("collaboration network thread started"); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = thread_evt_tx.try_send(NetEvent::EndpointOnline(false)); + tracing::error!(error = %format_args!("{error:#}"), "collaboration tokio runtime failed"); + return; + }, + }; + + if let Err(error) = runtime.block_on(net_main(cmd_rx, evt_tx)) { + let _ = thread_evt_tx.try_send(NetEvent::EndpointOnline(false)); + tracing::error!(error = %format_args!("{error:#}"), "collaboration networking stopped unexpectedly"); + } + tracing::debug!("collaboration network thread stopped"); + }) + .context("spawning collaboration networking thread failed")?; + + Ok(RuntimeBridge { + commands: cmd_tx, + events: evt_rx, + }) +} + +async fn net_main(cmd_rx: async_channel::Receiver, evt_tx: async_channel::Sender) -> Result<()> { + tracing::info!("binding collaboration endpoint"); + let endpoint = Endpoint::builder(presets::N0) + .bind() + .await + .context("binding collaboration endpoint failed")?; + let bound_addr = endpoint_addr(&endpoint); + tracing::info!(peer = %bound_addr.id, "collaboration endpoint bound"); + direct::install_endpoint(endpoint.clone()); + + let gossip = Gossip::builder().spawn(endpoint.clone()); + let direct_state = DirectServeState::default(); + let _router = iroh::protocol::Router::builder(endpoint.clone()) + .accept(iroh_gossip::ALPN, gossip.clone()) + .accept(crate::DIRECT_ALPN, DirectProto::new(direct_state.clone())) + .spawn(); + tracing::info!(peer = %bound_addr.id, "collaboration direct and gossip protocols installed"); + + let _ = evt_tx.send(NetEvent::EndpointOnline(true)).await; + let mut swarms = HashMap::new(); + + while let Ok(command) = cmd_rx.recv().await { + tracing::trace!(command = command.kind(), "collaboration network command received"); + match command { + NetCommand::EnsureUp => { + tracing::debug!("collaboration endpoint ensure-up requested"); + let _ = evt_tx.send(NetEvent::EndpointOnline(true)).await; + }, + NetCommand::RegisterDirectHandler { session, handler } => { + tracing::debug!(%session, "registering collaboration direct handler"); + direct_state.register_handler(session, handler).await; + }, + NetCommand::CreateSession { session, reply } => { + let result = create_session(&mut swarms, &endpoint, &gossip, &direct_state, &evt_tx, session).await; + if let Err(error) = &result { + tracing::warn!(%session, error = %format_args!("{error:#}"), "collaboration create session failed"); + } + let _ = reply.send(result).await; + }, + NetCommand::JoinSession { session, bootstrap } => { + if let Err(error) = join_session(&mut swarms, &endpoint, &gossip, &direct_state, &evt_tx, session, bootstrap).await { + tracing::warn!(%session, error = %format_args!("{error:#}"), "collaboration join session failed"); + let _ = evt_tx + .send(NetEvent::SubscribeFailed { + session, + error: format!("{error:#}"), + }) + .await; + } + }, + NetCommand::LeaveSession { session } => { + tracing::info!(%session, "leaving collaboration network session"); + if let Some(handle) = swarms.remove(&session) { + handle.stop().await; + } else { + tracing::debug!(%session, "leave requested for missing collaboration swarm"); + } + direct_state.detach_session(session).await; + }, + NetCommand::Publish { session, payload } => { + let payload_kind = payload.kind(); + let payload_bytes = payload.byte_len(); + if let Some(handle) = swarms.get(&session) { + tracing::trace!(%session, payload_kind, payload_bytes, "publishing collaboration payload"); + if let Err(error) = handle.publish(payload).await { + tracing::warn!(%session, payload_kind, payload_bytes, error = %format_args!("{error:#}"), "collaboration publish failed"); + } + } else { + tracing::warn!(%session, payload_kind, payload_bytes, "dropped collaboration publish for missing swarm"); + } + }, + NetCommand::PullUpdates { + session, + candidates, + our_vv, + reply, + } => { + let candidate_count = candidates.len(); + let vv_bytes = our_vv.len(); + tracing::debug!(%session, candidate_count, vv_bytes, "pulling collaboration updates"); + let result = direct::pull_with_fallback(DirectRequest::Updates { session, have_vv: our_vv }, candidates, DIRECT_PULL_TIMEOUT).await; + log_direct_pull_result("updates", session, candidate_count, &result); + let _ = reply.send(result).await; + }, + NetCommand::PullSnapshot { + session, + candidates, + progress, + reply, + } => { + let candidate_count = candidates.len(); + tracing::debug!(%session, candidate_count, "pulling collaboration snapshot"); + let result = direct::pull_with_fallback_progress(DirectRequest::Snapshot { session }, candidates, DIRECT_PULL_TIMEOUT, progress).await; + log_direct_pull_result("snapshot", session, candidate_count, &result); + let _ = reply.send(result).await; + }, + NetCommand::PullBlob { + session, + candidates, + blob, + reply, + } => { + let candidate_count = candidates.len(); + tracing::debug!(%session, ?blob, candidate_count, "pulling collaboration blob"); + let result = direct::pull_with_fallback(DirectRequest::Blob { session, blob }, candidates, DIRECT_PULL_TIMEOUT).await; + log_direct_pull_result("blob", session, candidate_count, &result); + let _ = reply.send(result).await; + }, + NetCommand::PullAsset { + session, + candidates, + asset, + reply, + } => { + let candidate_count = candidates.len(); + tracing::debug!(%session, asset, candidate_count, "pulling collaboration asset"); + let result = direct::pull_with_fallback(DirectRequest::Asset { session, asset }, candidates, DIRECT_PULL_TIMEOUT) + .await + .map(|bytes| AssetBytes { bytes }); + match &result { + Ok(bytes) => tracing::debug!(%session, asset, candidate_count, bytes = bytes.bytes.len(), "collaboration asset pull succeeded"), + Err(error) => tracing::warn!(%session, asset, candidate_count, error = %format_args!("{error:#}"), "collaboration asset pull failed"), + } + let _ = reply.send(result).await; + }, + NetCommand::MintTicketAddr { reply } => { + let addr = reachable_endpoint_addr(&endpoint).await; + tracing::debug!(peer = %addr.id, "minting collaboration ticket address"); + let _ = reply.send(addr).await; + }, + NetCommand::Shutdown => { + tracing::info!(open_sessions = swarms.len(), "shutting down collaboration network runtime"); + break; + }, + } + } + + for (_, handle) in swarms { + tracing::debug!(session = %handle.session, "stopping collaboration swarm during shutdown"); + handle.stop().await; + } + endpoint.close().await; + tracing::info!(peer = %bound_addr.id, "collaboration endpoint closed"); + Ok(()) +} + +async fn create_session( + swarms: &mut HashMap, + endpoint: &Endpoint, + gossip: &Gossip, + direct_state: &DirectServeState, + evt_tx: &async_channel::Sender, + session: SessionId, +) -> Result { + tracing::info!(%session, "creating collaboration network session"); + direct_state.attach_session(session).await; + replace_swarm( + swarms, + SwarmHandle::spawn( + endpoint.clone(), + gossip.clone(), + direct_state.clone(), + session, + Vec::new(), + evt_tx.clone(), + )?, + ) + .await; + let inviter = reachable_endpoint_addr(endpoint).await; + tracing::debug!(%session, peer = %inviter.id, "collaboration network session created"); + Ok(TicketSeed { inviter }) +} + +async fn join_session( + swarms: &mut HashMap, + endpoint: &Endpoint, + gossip: &Gossip, + direct_state: &DirectServeState, + evt_tx: &async_channel::Sender, + session: SessionId, + bootstrap: Vec, +) -> Result<()> { + let bootstrap_count = bootstrap.len(); + tracing::info!(%session, bootstrap_count, "joining collaboration network session"); + direct_state.attach_session(session).await; + replace_swarm( + swarms, + SwarmHandle::spawn(endpoint.clone(), gossip.clone(), direct_state.clone(), session, bootstrap, evt_tx.clone())?, + ) + .await; + tracing::debug!(%session, bootstrap_count, "collaboration network join subscribed"); + Ok(()) +} + +async fn replace_swarm(swarms: &mut HashMap, handle: SwarmHandle) { + let session = handle.session; + if let Some(previous) = swarms.insert(session, handle) { + tracing::debug!(%session, "replacing existing collaboration swarm"); + previous.stop().await; + } +} + +fn endpoint_addr(endpoint: &Endpoint) -> EndpointAddr { + endpoint.addr() +} + +fn log_direct_pull_result(kind: &'static str, session: crate::SessionId, candidate_count: usize, result: &Result>) { + match result { + Ok(bytes) => tracing::debug!(%session, kind, candidate_count, bytes = bytes.len(), "collaboration direct pull succeeded"), + Err(error) => tracing::warn!(%session, kind, candidate_count, error = %format_args!("{error:#}"), "collaboration direct pull failed"), + } +} + +async fn reachable_endpoint_addr(endpoint: &Endpoint) -> EndpointAddr { + let mut addr_watcher = endpoint.watch_addr(); + let wait = async { + loop { + if !addr_watcher.get().is_empty() { + return; + } + tokio::select! { + () = endpoint.online() => return, + updated = addr_watcher.updated() => { + if updated.is_err() { + return; + } + }, + } + } + }; + let _ = timeout(ENDPOINT_ADDR_READY_TIMEOUT, wait).await; + endpoint_addr(endpoint) +} diff --git a/crates/flowstate-collab/src/net/swarm.rs b/crates/flowstate-collab/src/net/swarm.rs new file mode 100644 index 00000000..e6b0567d --- /dev/null +++ b/crates/flowstate-collab/src/net/swarm.rs @@ -0,0 +1,319 @@ +use anyhow::{Context as _, Result}; +use async_channel::{Receiver, Sender}; +use iroh::{Endpoint, EndpointAddr, EndpointId, address_lookup::memory::MemoryLookup}; +use iroh_gossip::{ + api::{Event, GossipSender}, + net::Gossip, + proto::TopicId, +}; +use n0_future::StreamExt as _; + +use crate::{ + ids::SessionId, + proto_gossip::{self, GOSSIP_INLINE_LIMIT, GossipMsg}, +}; + +use super::{NetEvent, PublishPayload, direct::DirectServeState}; + +#[derive(Debug)] +enum SwarmCommand { + Publish(PublishPayload), + Stop, +} + +#[derive(Clone, Debug)] +pub struct SwarmHandle { + pub session: SessionId, + tx: Sender, +} + +impl SwarmHandle { + pub fn spawn( + endpoint: Endpoint, + gossip: Gossip, + direct_state: DirectServeState, + session: SessionId, + bootstrap: Vec, + evt_tx: Sender, + ) -> Result { + tracing::info!(%session, bootstrap_count = bootstrap.len(), "spawning collaboration gossip swarm"); + register_bootstrap_addrs(&endpoint, &bootstrap)?; + let bootstrap_ids = bootstrap.iter().map(endpoint_id).collect::>(); + let local_peer = endpoint_id(&endpoint.addr()); + let (tx, rx) = async_channel::unbounded(); + tokio::spawn(async move { + if let Err(error) = run_session(gossip, direct_state, session, local_peer, bootstrap_ids, rx, evt_tx.clone()).await { + tracing::error!(%session, error = %format_args!("{error:#}"), "collaboration gossip swarm failed"); + let _ = evt_tx + .send(NetEvent::SubscribeFailed { + session, + error: format!("{error:#}"), + }) + .await; + } + }); + Ok(Self { session, tx }) + } + + pub async fn publish(&self, payload: PublishPayload) -> Result<()> { + tracing::trace!(session = %self.session, payload_kind = payload.kind(), payload_bytes = payload.byte_len(), "queueing collaboration gossip publish"); + self + .tx + .send(SwarmCommand::Publish(payload)) + .await + .context("collaboration swarm task stopped") + } + + pub async fn stop(&self) { + tracing::debug!(session = %self.session, "stopping collaboration gossip swarm"); + let _ = self.tx.send(SwarmCommand::Stop).await; + } +} + +async fn run_session( + gossip: Gossip, + direct_state: DirectServeState, + session: SessionId, + local_peer: EndpointId, + bootstrap: Vec, + rx: Receiver, + evt_tx: Sender, +) -> Result<()> { + tracing::info!(%session, bootstrap_count = bootstrap.len(), "subscribing to collaboration gossip topic"); + let topic = gossip + .subscribe(topic_id(session), bootstrap) + .await + .context("subscribing to collaboration gossip topic failed")?; + tracing::info!(%session, "subscribed to collaboration gossip topic"); + let (sender, mut receiver) = topic.split(); + + loop { + tokio::select! { + command = rx.recv() => { + match command { + Ok(SwarmCommand::Publish(payload)) => publish(&sender, &direct_state, session, payload).await?, + Ok(SwarmCommand::Stop) => { + tracing::debug!(%session, "collaboration gossip swarm stop received"); + break; + }, + Err(error) => { + tracing::debug!(%session, error = %error, "collaboration gossip swarm command channel closed"); + break; + }, + } + }, + event = receiver.next() => { + let event = event.context("collaboration gossip receiver ended")??; + handle_event(event, session, local_peer, &evt_tx).await?; + }, + } + } + + tracing::info!(%session, "collaboration gossip swarm stopped"); + Ok(()) +} + +async fn publish(sender: &GossipSender, direct_state: &DirectServeState, session: SessionId, payload: PublishPayload) -> Result<()> { + let payload_kind = payload.kind(); + let payload_bytes = payload.byte_len(); + let (message, neighbors_only) = match payload { + PublishPayload::Update(bytes) => (update_message(direct_state, session, bytes).await?, false), + PublishPayload::Presence(bytes) => (GossipMsg::Presence(bytes), false), + PublishPayload::Digest { vv } => (GossipMsg::Digest { session, vv }, true), + }; + + let frame = if neighbors_only { + proto_gossip::encode(&message)? + } else { + proto_gossip::encode_inline(&message)? + }; + let frame_bytes = frame.len(); + tracing::trace!( + %session, + payload_kind, + payload_bytes, + gossip_kind = message.kind(), + gossip_payload_bytes = message.payload_len(), + frame_bytes, + neighbors_only, + "broadcasting collaboration gossip frame", + ); + + if neighbors_only { + sender.broadcast_neighbors(frame.into()).await?; + } else { + sender.broadcast(frame.into()).await?; + } + tracing::trace!(%session, payload_kind, frame_bytes, neighbors_only, "collaboration gossip frame broadcast complete"); + Ok(()) +} + +async fn update_message(direct_state: &DirectServeState, session: SessionId, bytes: Vec) -> Result { + let update_bytes = bytes.len(); + let inline = GossipMsg::Update(bytes.clone()); + if proto_gossip::encoded_len(&inline)? <= GOSSIP_INLINE_LIMIT { + tracing::trace!(%session, update_bytes, "collaboration update will be sent inline over gossip"); + return Ok(inline); + } + + let blob = direct_state.insert_blob(session, bytes).await?; + tracing::debug!(%session, ?blob, update_bytes, "collaboration update exceeded gossip limit; publishing blob announcement"); + Ok(GossipMsg::UpdateAvailable { + blob, + len: update_bytes as u64, + }) +} + +async fn handle_event(event: Event, session: SessionId, local_peer: EndpointId, evt_tx: &Sender) -> Result<()> { + match event { + Event::Received(message) if message.delivered_from == local_peer => { + tracing::trace!( + %session, + from = %message.delivered_from, + frame_bytes = message.content.len(), + "ignored self-delivered collaboration gossip frame", + ); + }, + Event::Received(message) => match proto_gossip::decode(&message.content) { + Ok(msg) => { + tracing::trace!( + %session, + from = %message.delivered_from, + gossip_kind = msg.kind(), + gossip_payload_bytes = msg.payload_len(), + frame_bytes = message.content.len(), + "received collaboration gossip frame", + ); + let _ = evt_tx + .send(NetEvent::Gossip { + session, + from: message.delivered_from, + msg, + }) + .await; + }, + Err(error) if proto_gossip::is_protocol_version_mismatch(&error) => { + let _ = evt_tx + .send(NetEvent::IncompatibleVersion { + session, + peer: message.delivered_from, + }) + .await; + }, + Err(error) => tracing::warn!( + %session, + from = %message.delivered_from, + frame_bytes = message.content.len(), + error = %format_args!("{error:#}"), + "ignored malformed collaboration gossip frame", + ), + }, + Event::NeighborUp(peer) => { + tracing::info!(%session, peer = %peer, "collaboration gossip neighbor up"); + let _ = evt_tx.send(NetEvent::NeighborUp { session, peer }).await; + }, + Event::NeighborDown(peer) => { + tracing::info!(%session, peer = %peer, "collaboration gossip neighbor down"); + let _ = evt_tx.send(NetEvent::NeighborDown { session, peer }).await; + }, + Event::Lagged => { + tracing::warn!(%session, "collaboration gossip receiver lagged"); + let _ = evt_tx.send(NetEvent::GossipLagged { session }).await; + }, + } + Ok(()) +} + +fn register_bootstrap_addrs(endpoint: &Endpoint, bootstrap: &[EndpointAddr]) -> Result<()> { + if bootstrap.is_empty() { + return Ok(()); + } + tracing::debug!(bootstrap_count = bootstrap.len(), "registering collaboration bootstrap addresses"); + endpoint + .address_lookup()? + .add(MemoryLookup::from_endpoint_info(bootstrap.iter().cloned())); + Ok(()) +} + +fn endpoint_id(addr: &EndpointAddr) -> EndpointId { + addr.id +} + +fn topic_id(session: SessionId) -> TopicId { + TopicId::from_bytes(*session.as_bytes()) +} + +#[cfg(test)] +mod tests { + use iroh::SecretKey; + use iroh_gossip::{ + api::{Event, Message}, + proto::DeliveryScope, + }; + + use crate::{ + SessionId, + net::NetEvent, + proto_gossip::{self, GossipMsg}, + }; + + use super::handle_event; + + #[tokio::test] + async fn version_mismatch_emits_incompatible_version_once() { + let session = SessionId::new(); + let local_peer = SecretKey::generate().public(); + let peer = SecretKey::generate().public(); + let mut frame = proto_gossip::encode(&GossipMsg::Presence(Vec::new())).expect("valid gossip frame"); + frame[0] = frame[0].wrapping_add(1); + + let (evt_tx, evt_rx) = async_channel::unbounded(); + handle_event( + Event::Received(Message { + content: frame.into(), + scope: DeliveryScope::Neighbors, + delivered_from: peer, + }), + session, + local_peer, + &evt_tx, + ) + .await + .expect("gossip event handling should ignore mismatches"); + + match evt_rx.recv().await.expect("incompatible-version event") { + NetEvent::IncompatibleVersion { + session: event_session, + peer: event_peer, + } => { + assert_eq!(event_session, session); + assert_eq!(event_peer, peer); + }, + event => panic!("unexpected event: {event:?}"), + } + assert!(evt_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn self_delivered_gossip_frame_is_ignored() { + let session = SessionId::new(); + let local_peer = SecretKey::generate().public(); + let frame = proto_gossip::encode(&GossipMsg::Update(vec![1, 2, 3])).expect("valid gossip frame"); + + let (evt_tx, evt_rx) = async_channel::unbounded(); + handle_event( + Event::Received(Message { + content: frame.into(), + scope: DeliveryScope::Neighbors, + delivered_from: local_peer, + }), + session, + local_peer, + &evt_tx, + ) + .await + .expect("self-delivered gossip frames should be ignored"); + + assert!(evt_rx.try_recv().is_err()); + } +} diff --git a/crates/flowstate-collab/src/presence.rs b/crates/flowstate-collab/src/presence.rs new file mode 100644 index 00000000..b0152e61 --- /dev/null +++ b/crates/flowstate-collab/src/presence.rs @@ -0,0 +1,224 @@ +//! Presence state and roster derivation. + +use anyhow::{Context as _, Result}; +use loro::{ + LoroValue, Subscription, + awareness::{EphemeralStore, EphemeralStoreEvent}, +}; +use serde::{Deserialize, Serialize}; + +use crate::ids::{PALETTE, PeerId, SessionId}; + +pub const PRESENCE_TIMEOUT_MS: i64 = 30_000; +pub const PRESENCE_KEEPALIVE_SECS: u64 = 10; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PresenceState { + pub name: String, + pub selection: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PresenceSelection { + pub anchor: SelectionEndpoint, + pub head: SelectionEndpoint, + pub direction: SelectionDirection, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SelectionEndpoint { + pub cursor: Vec, + pub affinity: SelectionAffinity, + pub visual_gravity: VisualGravity, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum SelectionAffinity { + Before, + After, + Neutral, +} + +/// §16: the presence wire enum mirrors `gpui_flowtext::SelectionAffinity` +/// variant-for-variant, so the editor's stored affinity converts directly +/// without re-deriving a side from selection direction. Conversion is +/// centralized here rather than scattered across the runtime. +impl From for SelectionAffinity { + fn from(value: gpui_flowtext::SelectionAffinity) -> Self { + match value { + gpui_flowtext::SelectionAffinity::Before => Self::Before, + gpui_flowtext::SelectionAffinity::After => Self::After, + gpui_flowtext::SelectionAffinity::Neutral => Self::Neutral, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum VisualGravity { + Upstream, + Downstream, + Neutral, +} + +/// §16: the presence wire enum mirrors `gpui_flowtext::VisualGravity` +/// variant-for-variant, so the editor's stored gravity converts directly. +impl From for VisualGravity { + fn from(value: gpui_flowtext::VisualGravity) -> Self { + match value { + gpui_flowtext::VisualGravity::Upstream => Self::Upstream, + gpui_flowtext::VisualGravity::Downstream => Self::Downstream, + gpui_flowtext::VisualGravity::Neutral => Self::Neutral, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum SelectionDirection { + Forward, + Backward, + None, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RosterEntry { + pub key: String, + pub name: String, + pub color_rgb: u32, + pub selection: Option, +} + +#[derive(Clone, Debug)] +pub struct PresenceStore { + store: EphemeralStore, + self_key: String, + self_color: u32, +} + +impl PresenceStore { + #[must_use] + pub fn new(self_peer: &PeerId) -> Self { + Self { + store: EphemeralStore::new(PRESENCE_TIMEOUT_MS), + self_key: peer_key(self_peer), + self_color: color_for_peer(self_peer), + } + } + + #[must_use] + pub fn self_key(&self) -> &str { + &self.self_key + } + + #[must_use] + pub const fn self_color(&self) -> u32 { + self.self_color + } + + pub fn set_self(&self, state: &PresenceState) -> Result<()> { + self + .store + .set(&self.self_key, LoroValue::Binary(encode_state(state)?.into())); + Ok(()) + } + + pub fn delete_self(&self) { + self.store.delete(&self.self_key); + } + + #[must_use] + pub fn encode_self(&self) -> Vec { + self.store.encode(&self.self_key) + } + + #[must_use] + pub fn encode_all(&self) -> Vec { + self.store.encode_all() + } + + pub fn apply(&self, bytes: &[u8]) -> Result<()> { + self + .store + .apply(bytes) + .map_err(|error| anyhow::anyhow!(error.to_string())) + .context("applying collaboration presence update failed") + } + + pub fn remove_outdated(&self) { + self.store.remove_outdated(); + } + + #[must_use] + pub fn roster(&self) -> Vec { + let mut entries = self + .store + .get_all_states() + .into_iter() + .filter_map(|(key, value)| roster_entry_from_value(key, value)) + .collect::>(); + entries.sort_by(|left, right| { + left + .name + .cmp(&right.name) + .then_with(|| left.key.cmp(&right.key)) + }); + entries + } + + pub fn subscribe_local_updates(&self, callback: impl Fn(&Vec) -> bool + Send + Sync + 'static) -> Subscription { + self.store.subscribe_local_updates(Box::new(callback)) + } + + pub fn subscribe(&self, callback: impl Fn(&EphemeralStoreEvent) -> bool + Send + Sync + 'static) -> Subscription { + self.store.subscribe(Box::new(callback)) + } +} + +pub fn encode_state(state: &PresenceState) -> Result> { + postcard::to_stdvec(state).context("encoding collaboration presence state failed") +} + +pub fn decode_state(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes).context("decoding collaboration presence state failed") +} + +#[must_use] +pub fn peer_key(peer: &PeerId) -> String { + let mut key = String::with_capacity(peer.as_bytes().len() * 2); + for byte in peer.as_bytes() { + use std::fmt::Write as _; + let _ = write!(&mut key, "{byte:02x}"); + } + key +} + +#[must_use] +pub fn color_for_peer(peer: &PeerId) -> u32 { + PALETTE[SessionId::color_index_for_peer(peer)] +} + +fn roster_entry_from_value(key: String, value: LoroValue) -> Option { + let LoroValue::Binary(bytes) = value else { + return None; + }; + let state = decode_state(bytes.as_ref()).ok()?; + let color_rgb = color_for_peer_key(&key)?; + Some(RosterEntry { + color_rgb, + key, + name: state.name, + selection: state.selection, + }) +} + +fn color_for_peer_key(key: &str) -> Option { + let mut bytes = Vec::with_capacity(key.len() / 2); + let mut chunks = key.as_bytes().chunks_exact(2); + if !chunks.remainder().is_empty() { + return None; + } + for chunk in &mut chunks { + let text = std::str::from_utf8(chunk).ok()?; + bytes.push(u8::from_str_radix(text, 16).ok()?); + } + Some(PALETTE[SessionId::color_index_for_peer_bytes(&bytes)]) +} diff --git a/crates/flowstate-collab/src/proto_direct.rs b/crates/flowstate-collab/src/proto_direct.rs new file mode 100644 index 00000000..721b3cfe --- /dev/null +++ b/crates/flowstate-collab/src/proto_direct.rs @@ -0,0 +1,68 @@ +use anyhow::{Result, anyhow, ensure}; +use serde::{Deserialize, Serialize}; + +use crate::ids::{BlobId, SessionId}; +use crate::proto_gossip::PROTOCOL_VERSION; + +pub const DIRECT_ALPN: &[u8] = b"flowstate/collab-direct/0"; +pub const MAX_FRAME_LEN: usize = 2 * 1024 * 1024; +pub const MAX_PAYLOAD_CHUNK_LEN: usize = 256 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum DirectRequest { + Snapshot { session: SessionId }, + Updates { session: SessionId, have_vv: Vec }, + Blob { session: SessionId, blob: BlobId }, + Asset { session: SessionId, asset: u128 }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum DirectResponseHeader { + Ok { total_len: u64 }, + NotAttached, + NotFound, + Busy, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetBytes { + pub bytes: Vec, +} + +pub fn encode_frame(value: &T) -> Result> { + let payload = postcard::to_stdvec(&(PROTOCOL_VERSION, value))?; + ensure!(payload.len() <= MAX_FRAME_LEN, "direct frame exceeds {MAX_FRAME_LEN} bytes"); + let len = payload.len() as u32; + let mut frame = len.to_le_bytes().to_vec(); + frame.extend(payload); + Ok(frame) +} + +pub fn decode_frame(frame: &[u8]) -> Result +where + T: for<'de> Deserialize<'de>, +{ + ensure!(frame.len() >= 4, "direct frame is missing its length prefix"); + let payload_len = u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; + ensure!(payload_len <= MAX_FRAME_LEN, "direct frame exceeds {MAX_FRAME_LEN} bytes"); + let payload = frame + .get(4..) + .ok_or_else(|| anyhow!("direct frame payload is missing"))?; + ensure!(payload.len() == payload_len, "direct frame length prefix does not match payload length"); + let (version, value): (u16, T) = postcard::from_bytes(payload)?; + if version != PROTOCOL_VERSION { + return Err(anyhow!("unsupported direct protocol version {version}")); + } + Ok(value) +} + +pub fn split_frame(frame: &[u8]) -> Result<(usize, &[u8])> { + ensure!(frame.len() >= 4, "direct frame is missing its length prefix"); + let payload_len = u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; + ensure!(payload_len <= MAX_FRAME_LEN, "direct frame exceeds {MAX_FRAME_LEN} bytes"); + let payload = frame + .get(4..) + .ok_or_else(|| anyhow!("direct frame payload is missing"))?; + ensure!(payload.len() == payload_len, "direct frame length prefix does not match payload length"); + Ok((payload_len, payload)) +} diff --git a/crates/flowstate-collab/src/proto_gossip.rs b/crates/flowstate-collab/src/proto_gossip.rs new file mode 100644 index 00000000..3d91b1d3 --- /dev/null +++ b/crates/flowstate-collab/src/proto_gossip.rs @@ -0,0 +1,83 @@ +use std::fmt; + +use anyhow::{Result, ensure}; +use serde::{Deserialize, Serialize}; + +use crate::ids::{BlobId, SessionId}; + +pub const PROTOCOL_VERSION: u16 = 1; +pub const GOSSIP_INLINE_LIMIT: usize = 2 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProtocolVersionMismatch { + pub version: u16, +} + +impl fmt::Display for ProtocolVersionMismatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unsupported gossip protocol version {}", self.version) + } +} + +impl std::error::Error for ProtocolVersionMismatch {} + +#[must_use] +pub fn is_protocol_version_mismatch(error: &anyhow::Error) -> bool { + error.is::() +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum GossipMsg { + Update(Vec), + UpdateAvailable { blob: BlobId, len: u64 }, + Presence(Vec), + Digest { session: SessionId, vv: Vec }, +} + +impl GossipMsg { + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Update(_) => "update", + Self::UpdateAvailable { .. } => "update_available", + Self::Presence(_) => "presence", + Self::Digest { .. } => "digest", + } + } + + #[must_use] + pub fn payload_len(&self) -> u64 { + match self { + Self::Update(bytes) | Self::Presence(bytes) => bytes.len() as u64, + Self::UpdateAvailable { len, .. } => *len, + Self::Digest { vv, .. } => vv.len() as u64, + } + } +} + +pub fn encode(msg: &GossipMsg) -> Result> { + let mut bytes = PROTOCOL_VERSION.to_le_bytes().to_vec(); + bytes.extend(postcard::to_stdvec(msg)?); + Ok(bytes) +} + +pub fn encode_inline(msg: &GossipMsg) -> Result> { + let bytes = encode(msg)?; + ensure!(bytes.len() <= GOSSIP_INLINE_LIMIT, "gossip frame exceeds {GOSSIP_INLINE_LIMIT} bytes"); + Ok(bytes) +} + +pub fn encoded_len(msg: &GossipMsg) -> Result { + // This intentionally uses the encoder: postcard has no stable counting + // serializer here, and callers only need a small inline/blob decision. + Ok(encode(msg)?.len()) +} + +pub fn decode(bytes: &[u8]) -> Result { + ensure!(bytes.len() >= 2, "gossip frame is missing protocol version"); + let version = u16::from_le_bytes([bytes[0], bytes[1]]); + if version != PROTOCOL_VERSION { + return Err(ProtocolVersionMismatch { version }.into()); + } + Ok(postcard::from_bytes(&bytes[2..])?) +} diff --git a/crates/flowstate-collab/src/self_check.rs b/crates/flowstate-collab/src/self_check.rs new file mode 100644 index 00000000..9db3fa2e --- /dev/null +++ b/crates/flowstate-collab/src/self_check.rs @@ -0,0 +1,33 @@ +//! Local projection drift detection. + +use std::hash::{Hash, Hasher}; + +use gpui_flowtext::{Block, DocumentProjection, paragraph_text}; +use twox_hash::XxHash64; + +#[must_use] +pub fn projection_hash(document: &DocumentProjection) -> u64 { + let mut hasher = XxHash64::default(); + let mut paragraph_ix = 0; + for block in document.blocks.iter() { + match block { + Block::Paragraph(paragraph) => { + paragraph.style.hash(&mut hasher); + for run in ¶graph.runs { + run.len.hash(&mut hasher); + run.styles.hash(&mut hasher); + } + paragraph_text(document, paragraph_ix) + .as_bytes() + .hash(&mut hasher); + "p".hash(&mut hasher); + paragraph_ix += 1; + }, + Block::Image(_) | Block::Equation(_) | Block::Table(_) => { + std::mem::discriminant(block).hash(&mut hasher); + format!("{block:?}").hash(&mut hasher); + }, + } + } + hasher.finish() +} diff --git a/crates/flowstate-collab/src/ticket.rs b/crates/flowstate-collab/src/ticket.rs new file mode 100644 index 00000000..e0ed76e6 --- /dev/null +++ b/crates/flowstate-collab/src/ticket.rs @@ -0,0 +1,59 @@ +use iroh::EndpointAddr; +use iroh_tickets::{ParseError, Ticket}; +use serde::{Deserialize, Serialize}; + +use crate::{SessionId, proto_gossip::PROTOCOL_VERSION}; + +pub const TICKET_KIND: &str = "fscollab"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SessionTicket { + pub version: u16, + pub session: SessionId, + pub inviter: EndpointAddr, + pub title: String, +} + +impl SessionTicket { + #[must_use] + pub fn new(session: SessionId, inviter: EndpointAddr, title: String) -> Self { + Self { + version: PROTOCOL_VERSION, + session, + inviter, + title, + } + } + + #[must_use] + pub const fn is_supported_version(&self) -> bool { + self.version == PROTOCOL_VERSION + } + + #[must_use] + pub fn encode_text(&self) -> String { + self.encode_string() + } + + pub fn decode_text(text: &str) -> Result { + Self::decode_string(text.trim()) + } +} + +impl Ticket for SessionTicket { + const KIND: &'static str = TICKET_KIND; + + /// `iroh_tickets::Ticket::encode_bytes` cannot return a `Result`; `SessionTicket` + /// only contains postcard-serializable fields, so serialization failure would be a programmer error. + fn encode_bytes(&self) -> Vec { + postcard::to_stdvec(self).expect("SessionTicket serialization should not fail") + } + + fn decode_bytes(bytes: &[u8]) -> Result { + let ticket: Self = postcard::from_bytes(bytes).map_err(|_| ParseError::verification_failed("invalid collaboration ticket payload"))?; + if !ticket.is_supported_version() { + return Err(ParseError::verification_failed("unsupported collaboration protocol version")); + } + Ok(ticket) + } +} diff --git a/crates/flowstate-collab/tests/anti_entropy.rs b/crates/flowstate-collab/tests/anti_entropy.rs new file mode 100644 index 00000000..81f58232 --- /dev/null +++ b/crates/flowstate-collab/tests/anti_entropy.rs @@ -0,0 +1,103 @@ +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use flowstate_collab::{ + SessionId, + net::anti_entropy::{AntiEntropyState, GapAction, VersionVectorRelation}, + }; + use iroh::{EndpointId, SecretKey}; + + #[test] + fn digest_gap_starts_one_pull_until_the_pull_finishes() { + let session = SessionId::from_bytes([1; 32]); + let peer = peer(9); + let mut state = AntiEntropyState::new(session, Instant::now()); + + assert_eq!( + state.consider_digest(peer, session, VersionVectorRelation::SenderHasMissingOps, vec![1, 2, 3]), + GapAction::Pull { + from: peer, + our_vv: vec![1, 2, 3], + } + ); + assert_eq!( + state.consider_digest(peer, session, VersionVectorRelation::Concurrent, vec![4, 5]), + GapAction::None + ); + + state.finish_pull(); + assert_eq!( + state.consider_digest(peer, session, VersionVectorRelation::Concurrent, vec![6]), + GapAction::Pull { from: peer, our_vv: vec![6] } + ); + } + + #[test] + fn equal_or_sender_behind_digest_does_not_pull() { + let session = SessionId::from_bytes([2; 32]); + let peer = peer(10); + let mut state = AntiEntropyState::new(session, Instant::now()); + + assert_eq!( + state.consider_digest(peer, session, VersionVectorRelation::Equal, vec![1]), + GapAction::None + ); + assert_eq!( + state.consider_digest(peer, session, VersionVectorRelation::WeHaveMissingOps, vec![2]), + GapAction::None + ); + } + + #[test] + fn lineage_mismatch_is_reported_without_starting_a_pull() { + let session = SessionId::from_bytes([3; 32]); + let other = SessionId::from_bytes([4; 32]); + let peer = peer(11); + let mut state = AntiEntropyState::new(session, Instant::now()); + + assert_eq!( + state.consider_digest(peer, other, VersionVectorRelation::SenderHasMissingOps, vec![1]), + GapAction::LineageMismatch { + from: peer, + expected: session, + got: other, + } + ); + assert_eq!( + state.consider_digest(peer, session, VersionVectorRelation::SenderHasMissingOps, vec![2]), + GapAction::Pull { from: peer, our_vv: vec![2] } + ); + } + + #[test] + fn lagged_uses_the_fallback_peer_and_deduplicates_in_flight_pulls() { + let session = SessionId::from_bytes([5; 32]); + let peer = peer(12); + let mut state = AntiEntropyState::new(session, Instant::now()); + + assert_eq!(state.on_lagged(None, vec![1]), GapAction::None); + assert_eq!(state.on_lagged(Some(peer), vec![2]), GapAction::Pull { from: peer, our_vv: vec![2] }); + assert_eq!(state.on_lagged(Some(peer), vec![3]), GapAction::None); + } + + #[test] + fn neighbor_and_reconnect_events_schedule_an_immediate_digest() { + let session = SessionId::from_bytes([6; 32]); + let now = Instant::now(); + let mut state = AntiEntropyState::new(session, now); + + assert!(!state.digest_due(now + Duration::from_secs(1))); + state.on_neighbor_up(); + assert!(state.digest_due(Instant::now() + Duration::from_millis(1))); + + state.mark_digest_sent(Instant::now()); + assert!(!state.digest_due(Instant::now() + Duration::from_secs(1))); + state.on_reconnect(); + assert!(state.digest_due(Instant::now() + Duration::from_millis(1))); + } + + fn peer(seed: u8) -> EndpointId { + SecretKey::from_bytes(&[seed; 32]).public() + } +} diff --git a/crates/flowstate-collab/tests/swarm_loopback.rs b/crates/flowstate-collab/tests/swarm_loopback.rs new file mode 100644 index 00000000..2b05bf59 --- /dev/null +++ b/crates/flowstate-collab/tests/swarm_loopback.rs @@ -0,0 +1,295 @@ +#![cfg(test)] + +use std::time::Duration; + +use anyhow::{Context as _, Result, bail, ensure}; +use async_channel::Receiver; +use flowstate_collab::{ + DIRECT_ALPN, SessionId, + net::{ + NetEvent, PublishPayload, + direct::{self, DirectProto, DirectServeRequest, DirectServeState, DirectSessionHandler}, + swarm::SwarmHandle, + }, + proto_direct::DirectRequest, + proto_gossip::{GOSSIP_INLINE_LIMIT, GossipMsg}, +}; +use iroh::{Endpoint, EndpointAddr, Watcher as _, address_lookup::memory::MemoryLookup, endpoint::presets, protocol::Router}; +use iroh_gossip::net::Gossip; +use tokio::time::timeout; + +struct Peer { + endpoint: Endpoint, + gossip: Gossip, + direct_state: DirectServeState, + router: Router, + events: Receiver, + swarm: Option, +} + +impl Peer { + async fn spawn(lookup: MemoryLookup) -> Result { + let endpoint = Endpoint::builder(presets::Minimal) + .address_lookup(lookup) + .clear_ip_transports() + .bind_addr("127.0.0.1:0")? + .bind() + .await + .context("binding loopback endpoint failed")?; + let gossip = Gossip::builder().spawn(endpoint.clone()); + let direct_state = DirectServeState::default(); + let router = Router::builder(endpoint.clone()) + .accept(iroh_gossip::ALPN, gossip.clone()) + .accept(DIRECT_ALPN, DirectProto::new(direct_state.clone())) + .spawn(); + let (_, events) = async_channel::unbounded(); + + Ok(Self { + endpoint, + gossip, + direct_state, + router, + events, + swarm: None, + }) + } + + fn start_swarm(&mut self, session: SessionId, bootstrap: Vec) -> Result<()> { + let (evt_tx, evt_rx) = async_channel::unbounded(); + self.events = evt_rx; + self.swarm = Some(SwarmHandle::spawn( + self.endpoint.clone(), + self.gossip.clone(), + self.direct_state.clone(), + session, + bootstrap, + evt_tx, + )?); + Ok(()) + } + + async fn publish(&self, payload: PublishPayload) -> Result<()> { + let Some(swarm) = &self.swarm else { + bail!("peer swarm is not running"); + }; + swarm.publish(payload).await + } + + async fn stop_swarm(&mut self) { + if let Some(swarm) = self.swarm.take() { + swarm.stop().await; + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires local UDP loopback and real iroh gossip timing"] +async fn swarm_loopback_net_subset() -> Result<()> { + let session = SessionId::new(); + let lookup = MemoryLookup::new(); + let mut a = Peer::spawn(lookup.clone()).await?; + let mut b = Peer::spawn(lookup.clone()).await?; + let mut c = Peer::spawn(lookup.clone()).await?; + + let a_addr = wait_addr(&a.endpoint).await; + let b_addr = wait_addr(&b.endpoint).await; + let c_addr = wait_addr(&c.endpoint).await; + ensure!(!a_addr.is_empty(), "A endpoint did not expose loopback addresses"); + ensure!(!b_addr.is_empty(), "B endpoint did not expose loopback addresses"); + ensure!(!c_addr.is_empty(), "C endpoint did not expose loopback addresses"); + let addrs = [a_addr.clone(), b_addr.clone(), c_addr.clone()]; + for addr in addrs.iter().cloned() { + lookup.add_endpoint_info(addr); + } + for endpoint in [&a.endpoint, &b.endpoint, &c.endpoint] { + endpoint + .address_lookup()? + .add(MemoryLookup::from_endpoint_info(addrs.iter().cloned())); + } + + install_snapshot_handler(&a, session, b"snapshot-from-a".to_vec()).await; + let snapshot = direct::pull_with_endpoint( + &b.endpoint, + DirectRequest::Snapshot { session }, + vec![a.endpoint.id()], + Duration::from_secs(5), + ) + .await?; + assert_eq!(snapshot, b"snapshot-from-a".to_vec()); + + a.start_swarm(session, Vec::new())?; + b.start_swarm(session, vec![a_addr.clone()])?; + c.start_swarm(session, vec![b_addr.clone()])?; + recv_neighbor(&a.events).await?; + recv_neighbor(&b.events).await?; + recv_neighbor(&c.events).await?; + + let small_update = b"a-to-b-to-c".to_vec(); + a.publish(PublishPayload::Update(small_update.clone())) + .await?; + recv_update(&b.events, &small_update).await?; + recv_update(&c.events, &small_update).await?; + + a.publish(PublishPayload::Presence(b"presence-a".to_vec())) + .await?; + b.publish(PublishPayload::Presence(b"presence-b".to_vec())) + .await?; + c.publish(PublishPayload::Presence(b"presence-c".to_vec())) + .await?; + recv_presence(&a.events).await?; + recv_presence(&b.events).await?; + recv_presence(&c.events).await?; + + let large_update = vec![7; GOSSIP_INLINE_LIMIT + 1]; + a.publish(PublishPayload::Update(large_update.clone())) + .await?; + let blob = recv_update_available(&b.events, large_update.len() as u64).await?; + let blob_bytes = direct::pull_with_endpoint( + &b.endpoint, + DirectRequest::Blob { session, blob }, + vec![a.endpoint.id()], + Duration::from_secs(5), + ) + .await?; + assert_eq!(blob_bytes, large_update); + + a.stop_swarm().await; + let b_update = b"b-after-a-left".to_vec(); + b.publish(PublishPayload::Update(b_update.clone())).await?; + recv_update(&c.events, &b_update).await?; + let c_update = b"c-after-a-left".to_vec(); + c.publish(PublishPayload::Update(c_update.clone())).await?; + recv_update(&b.events, &c_update).await?; + + c.stop_swarm().await; + c.start_swarm(session, vec![b_addr])?; + b.publish(PublishPayload::Digest { + vv: b"digest-after-resubscribe".to_vec(), + }) + .await?; + recv_digest(&c.events).await?; + + a.router + .shutdown() + .await + .context("shutting down A router failed")?; + b.router + .shutdown() + .await + .context("shutting down B router failed")?; + c.router + .shutdown() + .await + .context("shutting down C router failed")?; + Ok(()) +} + +async fn install_snapshot_handler(peer: &Peer, session: SessionId, snapshot: Vec) { + let (request_tx, request_rx) = async_channel::unbounded(); + peer + .direct_state + .register_handler(session, DirectSessionHandler::new(request_tx)) + .await; + tokio::spawn(async move { + while let Ok(request) = request_rx.recv().await { + if let DirectServeRequest::Snapshot { reply } = request { + let _ = reply.send(Ok(snapshot.clone())).await; + } + } + }); +} + +async fn wait_addr(endpoint: &Endpoint) -> EndpointAddr { + let mut watcher = endpoint.watch_addr(); + let wait = async { + loop { + let addr = watcher.get(); + if !addr.is_empty() { + return addr; + } + if watcher.updated().await.is_err() { + return endpoint.addr(); + } + } + }; + timeout(Duration::from_secs(3), wait) + .await + .unwrap_or_else(|_| endpoint.addr()) +} + +async fn recv_update(events: &Receiver, expected: &[u8]) -> Result<()> { + recv_matching( + events, + "update", + |event| matches!(event, NetEvent::Gossip { msg: GossipMsg::Update(bytes), .. } if bytes == expected), + ) + .await + .map(|_| ()) +} + +async fn recv_presence(events: &Receiver) -> Result<()> { + recv_matching(events, "presence", |event| { + matches!( + event, + NetEvent::Gossip { + msg: GossipMsg::Presence(_), + .. + } + ) + }) + .await + .map(|_| ()) +} + +async fn recv_neighbor(events: &Receiver) -> Result<()> { + recv_matching(events, "neighbor", |event| matches!(event, NetEvent::NeighborUp { .. })) + .await + .map(|_| ()) +} + +async fn recv_update_available(events: &Receiver, expected_len: u64) -> Result { + let event = recv_matching( + events, + "update-available", + |event| matches!(event, NetEvent::Gossip { msg: GossipMsg::UpdateAvailable { len, .. }, .. } if *len == expected_len), + ) + .await?; + let NetEvent::Gossip { + msg: GossipMsg::UpdateAvailable { blob, .. }, + .. + } = event + else { + bail!("matched event was not update-available"); + }; + Ok(blob) +} + +async fn recv_digest(events: &Receiver) -> Result<()> { + recv_matching(events, "digest", |event| { + matches!( + event, + NetEvent::Gossip { + msg: GossipMsg::Digest { .. }, + .. + } + ) + }) + .await + .map(|_| ()) +} + +async fn recv_matching(events: &Receiver, label: &str, matches: F) -> Result +where + F: Fn(&NetEvent) -> bool, +{ + timeout(Duration::from_secs(10), async { + loop { + let event = events.recv().await.context("event channel closed")?; + if matches(&event) { + return Ok(event); + } + } + }) + .await + .with_context(|| format!("timed out waiting for {label}"))? +} diff --git a/crates/flowstate-document/Cargo.toml b/crates/flowstate-document/Cargo.toml index 7bc6bee1..3e45ead6 100644 --- a/crates/flowstate-document/Cargo.toml +++ b/crates/flowstate-document/Cargo.toml @@ -10,7 +10,16 @@ workspace = true [dependencies] gpui-flowtext = { workspace = true } gpui = { version = "0.2.2", default-features = false } +imagesize = { workspace = true } rustc-hash = { workspace = true } +blake3 = "1.8.5" +loro.workspace = true +postcard.workspace = true +serde.workspace = true +uuid.workspace = true +tempfile.workspace = true +zstd.workspace = true +atomicwrites = "0.4.4" [features] hotpath = ["gpui-flowtext/hotpath"] diff --git a/crates/flowstate-document/src/lib.rs b/crates/flowstate-document/src/lib.rs index 8fdc1edc..833fc736 100644 --- a/crates/flowstate-document/src/lib.rs +++ b/crates/flowstate-document/src/lib.rs @@ -1,6 +1,31 @@ +pub mod loro_import; +pub mod loro_projection; +pub mod loro_schema; +pub mod package; +mod package_search; + pub use gpui_flowtext::*; +pub use loro_import::{ + ImportedLoroDocument, document_to_loro, import_document_projection, import_paragraphs_as_loro, write_imported_document_as_loro_db8, +}; +pub use loro_projection::{document_from_loro, object_input_blocks_from_loro, section_page_attrs}; +pub use loro_schema::{ + BLOCKS_BY_ID, BODY_FLOW_ID, FLOW_ATTRS_KEY, FLOW_ID_KEY, FLOW_KIND_KEY, FLOW_TEXT_KEY, FLOWS_BY_ID, MAIN_BODY_BLOCK_ID, MARK_DIRECT_UNDERLINE, + MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, META, OBJECT_REPLACEMENT, PARAGRAPHS_BY_ID, + PageNumberFormat, REPLICAS_BY_ID, ROOT, ROOT_BODY_FLOW_ID, ROOT_FIRST_PARAGRAPH_ID, SECTION_ATTR_COLUMNS, SECTION_ATTR_FOOTER_FLOW_ID, + SECTION_ATTR_HEADER_FLOW_ID, SECTION_ATTR_MARGIN_BOTTOM, SECTION_ATTR_MARGIN_LEFT, SECTION_ATTR_MARGIN_RIGHT, SECTION_ATTR_MARGIN_TOP, + SECTION_ATTR_ORIENTATION, SECTION_ATTR_PAGE_HEIGHT, SECTION_ATTR_PAGE_NUMBERING_FORMAT, SECTION_ATTR_PAGE_NUMBERING_START, + SECTION_ATTR_PAGE_WIDTH, SECTIONS_BY_ID, SENTINEL_NEWLINE, SectionMargins, SectionOrientation, SectionPageAttrs, SectionPageNumbering, + SectionPageSize, USERS_BY_ID, document_id, document_schema_version, ensure_section, fork_document_lineage, init_loro_document, + new_loro_document, read_section_page_attrs, record_revision, register_replica, register_user, set_section_page_attrs, touch_document_metadata, +}; +pub use package::{ + AssetChunk, ChunkRef, DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD, DocumentPackage, DocumentPackageManifest, IntegrityIndexEntry, + LORO_PACKAGE_FORMAT_VERSION, LORO_SCHEMA_VERSION, PackageRevision, ProjectionCacheChunk, SchemaMigrationRecord, SearchUnitChunk, + ThumbnailChunk, loro_db8_bytes, read_loro_db8, write_loro_db8, +}; -use std::{io, path::Path}; +use std::{io, path::Path, sync::Arc}; use gpui::{Pixels, black, px, rgb}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -33,20 +58,66 @@ fn border_eighth_points(value: f32) -> Pixels { pt(value / 8.0) } -pub fn read_db8(path: impl AsRef) -> io::Result { - read_document(path) +pub fn read_db8(path: impl AsRef) -> io::Result { + document_from_package(DocumentPackage::read(path)?) } -pub fn read_db8_bytes(bytes: &[u8]) -> io::Result { - read_document_bytes(bytes) +pub fn read_db8_bytes(bytes: &[u8]) -> io::Result { + document_from_package(DocumentPackage::from_bytes(bytes)?) } -pub fn write_db8(path: impl AsRef, document: &Document) -> io::Result<()> { - write_document(path, document) +fn document_from_package(package: DocumentPackage) -> io::Result { + let mut document = if let Some(document) = package.current_projection_document()? { + document + } else { + document_from_loro(&package.load_loro_doc()?)? + }; + attach_package_assets(&mut document, &package.assets); + Ok(document) } -pub fn db8_bytes(document: &Document) -> io::Result> { - document_bytes(document) +pub fn attach_package_assets(document: &mut DocumentProjection, assets: &[AssetChunk]) { + let referenced = referenced_asset_ids(document); + for asset in assets + .iter() + .filter(|asset| referenced.contains(&AssetId(asset.asset_id))) + { + let bytes = asset.bytes.clone(); + document.assets.assets.insert( + AssetId(asset.asset_id), + AssetRecord { + id: AssetId(asset.asset_id), + mime_type: asset.mime_type.clone().into(), + original_name: None, + content_hash: AssetRecord::stable_content_hash(&bytes), + bytes: Arc::new(bytes), + }, + ); + } + for id in referenced { + document + .assets + .assets + .entry(id) + .or_insert_with(|| AssetRecord { + id, + mime_type: "application/octet-stream".into(), + original_name: None, + content_hash: AssetRecord::stable_content_hash(&[]), + bytes: Arc::new(Vec::new()), + }); + } +} + +fn referenced_asset_ids(document: &DocumentProjection) -> FxHashSet { + document + .blocks + .iter() + .filter_map(|block| match block { + Block::Image(image) => Some(image.asset_id), + Block::Paragraph(_) | Block::Equation(_) | Block::Table(_) => None, + }) + .collect() } pub fn flowstate_document_theme() -> DocumentTheme { diff --git a/crates/flowstate-document/src/loro_import.rs b/crates/flowstate-document/src/loro_import.rs new file mode 100644 index 00000000..edce4a0f --- /dev/null +++ b/crates/flowstate-document/src/loro_import.rs @@ -0,0 +1,967 @@ +use std::{io, path::Path}; + +use gpui_flowtext::{ + Block, BlockAlignment, DocumentParagraphInput, DocumentProjection, DocumentTheme, EquationDisplay, EquationSyntax, HighlightStyle, ImageBlock, + ImageSizing, Paragraph, ParagraphStyle, RunSemanticStyle, RunStyles, TableBlock, TableCellBlock, TableColumnWidth, document_from_paragraphs, + paragraph_text, +}; +use loro::{ContainerTrait as _, LoroDoc, LoroMap, LoroMovableList, LoroResult, LoroText, LoroValue, TextDelta, cursor::Side}; +use rustc_hash::FxHashMap; +use uuid::Uuid; + +use crate::{ + AssetChunk, BLOCKS_BY_ID, BODY_FLOW_ID, FLOW_ATTRS_KEY, FLOW_ID_KEY, FLOW_KIND_KEY, FLOW_TEXT_KEY, FLOWS_BY_ID, MARK_DIRECT_UNDERLINE, + MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, OBJECT_REPLACEMENT, PARAGRAPHS_BY_ID, ROOT, + ROOT_BODY_FLOW_ID, SECTIONS_BY_ID, + loro_schema::{ASSETS_BY_ID, REVISIONS, SectionPageAttrs, write_section_page_attrs}, +}; + +/// Canonical result of an external import. The Loro document is authoritative; +/// the projection is a frontier-matched initial view built from the same semantic +/// import plan so the UI does not need to project the document a second time. +pub struct ImportedLoroDocument { + pub doc: LoroDoc, + pub projection: DocumentProjection, +} + +pub fn import_document_projection(mut document: DocumentProjection, title: &str) -> io::Result { + let doc = crate::loro_schema::new_loro_import_document(title).map_err(loro_io_error)?; + if document.ids.document_id != 0 { + crate::loro_schema::set_document_id(&doc, Uuid::from_u128(document.ids.document_id)).map_err(loro_io_error)?; + } + replace_body_from_document(&doc, &document).map_err(loro_io_error)?; + import_assets(&doc, &document).map_err(loro_io_error)?; + doc.commit(); + document.frontier = doc.state_frontiers().encode(); + Ok(ImportedLoroDocument { doc, projection: document }) +} + +pub fn import_paragraphs_as_loro( + theme: DocumentTheme, + paragraphs: Vec, + title: &str, +) -> io::Result { + import_document_projection(document_from_paragraphs(theme, paragraphs), title) +} + +pub fn document_to_loro(document: &DocumentProjection, title: &str) -> io::Result { + Ok(import_document_projection(document.clone(), title)?.doc) +} + +pub fn write_imported_document_as_loro_db8(path: impl AsRef, document: &DocumentProjection, title: &str) -> io::Result<()> { + let imported = import_document_projection(document.clone(), title)?; + crate::DocumentPackage::from_loro_snapshot_with_assets(&imported.doc, title, assets_from_document(&imported.projection))?.write(path) +} + +pub(crate) fn replace_body_from_document(doc: &LoroDoc, document: &DocumentProjection) -> LoroResult<()> { + let root = doc.get_map(ROOT); + let flows = root.ensure_mergeable_map(FLOWS_BY_ID)?; + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID)?; + let paragraphs = root.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let sections = root.ensure_mergeable_map(SECTIONS_BY_ID)?; + root.ensure_mergeable_list(REVISIONS)?; + + let body_flow = ensure_flow(&flows, ROOT_BODY_FLOW_ID, "body")?; + let body_text = body_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + clear_map(&blocks)?; + clear_map(¶graphs)?; + clear_map(§ions)?; + + let plan = FlowTextImportPlan::for_document(document); + plan.write_to(&body_text)?; + + let mut paragraph_ix = 0_usize; + for (block_ix, (block, position)) in document + .blocks + .iter() + .zip(&plan.block_positions) + .enumerate() + { + match (block, position) { + (Block::Paragraph(_), FlowBlockPosition::Paragraph { boundary_pos, .. }) => { + import_paragraph_record( + ¶graphs, + &blocks, + BODY_FLOW_ID, + &body_text, + *boundary_pos, + projection_block_id(document, block_ix, "paragraph_block"), + projection_paragraph_id(document, paragraph_ix), + )?; + paragraph_ix += 1; + }, + (Block::Image(image), FlowBlockPosition::Object { anchor_pos }) => { + import_image_block( + &flows, + &blocks, + document, + image, + projection_block_id(document, block_ix, "image"), + &body_text, + *anchor_pos, + )?; + }, + (Block::Equation(equation), FlowBlockPosition::Object { anchor_pos }) => { + let durable_block_id = projection_block_id(document, block_ix, "equation"); + let block = ensure_block(&blocks, durable_block_id.clone(), "equation", BODY_FLOW_ID, &body_text, *anchor_pos)?; + let source_flow_id = nested_flow_id("equation_source", &durable_block_id); + block.insert("source_flow_id", source_flow_id.as_str())?; + let source_flow = ensure_flow(&flows, &source_flow_id, "equation_source")?; + replace_text(&source_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?, equation.source.as_ref())?; + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("syntax", equation_syntax_name(equation.syntax))?; + attrs.insert("display", equation_display_name(equation.display))?; + }, + (Block::Table(table), FlowBlockPosition::Object { anchor_pos }) => { + let durable_block_id = projection_block_id(document, block_ix, "table"); + let block = ensure_block(&blocks, durable_block_id.clone(), "table", BODY_FLOW_ID, &body_text, *anchor_pos)?; + import_table(&flows, &block, table, &durable_block_id)?; + }, + _ => unreachable!("flow import plan must preserve document block shape"), + } + } + + import_sections(document, §ions, &flows, &body_text, &plan.paragraphs)?; + Ok(()) +} + +fn import_image_block( + flows: &LoroMap, + blocks: &LoroMap, + document: &DocumentProjection, + image: &ImageBlock, + durable_block_id: String, + body_text: &LoroText, + anchor_pos: usize, +) -> LoroResult<()> { + let block = ensure_block(blocks, durable_block_id.clone(), "image", BODY_FLOW_ID, body_text, anchor_pos)?; + block.insert("asset_id", image.asset_id.0.to_string())?; + if let Some(asset) = document.assets.assets.get(&image.asset_id) { + block.insert("content_hash", blake3::hash(&asset.bytes).to_hex().as_str())?; + block.insert("mime_type", asset.mime_type.as_ref())?; + block.insert("byte_length", i64::try_from(asset.bytes.len()).unwrap_or(i64::MAX))?; + } + let alt_text_flow_id = nested_flow_id("image_alt", &durable_block_id); + block.insert("alt_text_flow_id", alt_text_flow_id.as_str())?; + let alt_flow = ensure_flow(flows, &alt_text_flow_id, "alt_text")?; + replace_text(&alt_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?, image.alt_text.as_ref())?; + if let Some(caption) = &image.caption { + let caption_flow_id = nested_flow_id("image_caption", &durable_block_id); + block.insert("caption_flow_id", caption_flow_id.as_str())?; + let caption_flow = ensure_flow(flows, &caption_flow_id, "caption")?; + let caption_text = caption_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let mut caption_plan = FlowTextImportPlan::new(1, caption.runs.len().saturating_add(1)); + caption_plan.push_paragraph(caption, ""); + caption_plan.write_to(&caption_text)?; + } + let attrs = block.ensure_mergeable_map("attrs")?; + attrs.insert("alignment", alignment_name(image.alignment))?; + match image.sizing { + ImageSizing::Intrinsic => attrs.insert("sizing", "intrinsic")?, + ImageSizing::FitWidth => attrs.insert("sizing", "fit_width")?, + ImageSizing::Fixed { width_px, height_px } => { + attrs.insert("sizing", "fixed")?; + attrs.insert("width_px", i64::from(width_px))?; + if let Some(height_px) = height_px { + attrs.insert("height_px", i64::from(height_px))?; + } + }, + }; + Ok(()) +} + +fn import_sections( + document: &DocumentProjection, + sections: &LoroMap, + flows: &LoroMap, + body_text: &LoroText, + paragraph_plans: &[ParagraphTextImportPlan], +) -> LoroResult<()> { + let paragraph_indexes = document + .ids + .paragraph_ids + .iter() + .enumerate() + .map(|(ix, id)| (*id, ix)) + .collect::>(); + for section in document.sections.iter() { + let section_id = section.id.0.to_string(); + let section_map = sections.ensure_mergeable_map(§ion_id)?; + section_map.insert("id", section_id.as_str())?; + section_map.insert("container_id", section_map.id().to_string())?; + section_map.insert("start_paragraph_id", section.start_paragraph.0.to_string())?; + if let Some(parent_id) = section.parent_id { + section_map.insert("parent_section_id", parent_id.0.to_string())?; + } + if let Some(heading_id) = section.heading_paragraph { + section_map.insert("heading_paragraph_id", heading_id.0.to_string())?; + } + if let Some(end_id) = section.end_paragraph_exclusive { + section_map.insert("end_paragraph_exclusive_id", end_id.0.to_string())?; + } + let gpui_flowtext::SectionKind::Custom(kind_slot) = section.kind; + section_map.insert("kind_slot", i64::from(kind_slot))?; + if let Some(paragraph_ix) = paragraph_indexes.get(§ion.start_paragraph).copied() + && let Some(boundary_pos) = paragraph_plans + .get(paragraph_ix) + .map(|paragraph| paragraph.boundary_pos) + && let Some(cursor) = body_text.get_cursor(boundary_pos, Side::Left) + { + section_map.insert("start_cursor", cursor.encode())?; + } + let attrs = section_map.ensure_mergeable_map("attrs")?; + section_map.insert("attrs_container_id", attrs.id().to_string())?; + attrs.insert("source", "paragraph_style_outline")?; + // §11/§31: persist this section's page-structure attrs so they round-trip + // losslessly through Loro. When the projection carries them on + // `DocumentSection::page`, map them field-for-field onto the canonical Loro + // mirror and write them (the writer also creates any referenced + // header/footer flows via the `flows` map). Page-less sections fall back to + // the documented defaults (US Letter, 1-inch margins, 1 column, portrait, + // no numbering, no header/footer), matching the read path which always + // projects `Some(..)`. + let page_attrs = section + .page + .as_ref() + .map_or_else(SectionPageAttrs::default, section_page_attrs_to_loro); + write_section_page_attrs(&attrs, flows, &page_attrs)?; + } + Ok(()) +} + +/// §11: map a section's gpui-flowtext page-structure attrs onto the canonical +/// Loro mirror (`crate::loro_schema::SectionPageAttrs`) for the import write +/// path. This is the exact inverse of `loro_projection`'s +/// `project_section_page_attrs` read mapping; fully-qualified paths disambiguate +/// the structurally-identical `gpui_flowtext` and `crate::loro_schema` type +/// names. The owned header/footer flow id strings are cloned because the source +/// section is borrowed from the projection. +fn section_page_attrs_to_loro(page: &gpui_flowtext::SectionPageAttrs) -> SectionPageAttrs { + SectionPageAttrs { + page_size: crate::loro_schema::SectionPageSize { + width_twips: page.page_size.width_twips, + height_twips: page.page_size.height_twips, + }, + margins: crate::loro_schema::SectionMargins { + top_twips: page.margins.top_twips, + right_twips: page.margins.right_twips, + bottom_twips: page.margins.bottom_twips, + left_twips: page.margins.left_twips, + }, + columns: page.columns, + orientation: match page.orientation { + gpui_flowtext::SectionOrientation::Portrait => crate::loro_schema::SectionOrientation::Portrait, + gpui_flowtext::SectionOrientation::Landscape => crate::loro_schema::SectionOrientation::Landscape, + }, + page_numbering: crate::loro_schema::SectionPageNumbering { + format: match page.page_numbering.format { + gpui_flowtext::PageNumberFormat::None => crate::loro_schema::PageNumberFormat::None, + gpui_flowtext::PageNumberFormat::Decimal => crate::loro_schema::PageNumberFormat::Decimal, + gpui_flowtext::PageNumberFormat::LowerRoman => crate::loro_schema::PageNumberFormat::LowerRoman, + gpui_flowtext::PageNumberFormat::UpperRoman => crate::loro_schema::PageNumberFormat::UpperRoman, + gpui_flowtext::PageNumberFormat::LowerAlpha => crate::loro_schema::PageNumberFormat::LowerAlpha, + gpui_flowtext::PageNumberFormat::UpperAlpha => crate::loro_schema::PageNumberFormat::UpperAlpha, + }, + start: page.page_numbering.start, + }, + header_flow_id: page.header_flow_id.clone(), + footer_flow_id: page.footer_flow_id.clone(), + } +} + +fn import_paragraph_record( + paragraphs: &LoroMap, + blocks: &LoroMap, + flow_id: &str, + body_text: &LoroText, + boundary_pos: usize, + block_id: String, + paragraph_id: String, +) -> LoroResult<()> { + let paragraph_map = paragraphs.ensure_mergeable_map(¶graph_id)?; + paragraph_map.insert("id", paragraph_id.as_str())?; + paragraph_map.insert("container_id", paragraph_map.id().to_string())?; + paragraph_map.insert("flow_id", flow_id)?; + if let Some(cursor) = body_text.get_cursor(boundary_pos, Side::Left) { + paragraph_map.insert("start_cursor", cursor.encode())?; + } + if let Some(cursor) = body_text.get_cursor(boundary_pos, Side::Right) { + paragraph_map.insert("boundary_cursor", cursor.encode())?; + } + let attrs = paragraph_map.ensure_mergeable_map("attrs")?; + paragraph_map.insert("attrs_container_id", attrs.id().to_string())?; + ensure_block(blocks, block_id, "paragraph", flow_id, body_text, boundary_pos)?; + Ok(()) +} + +#[derive(Clone, Copy, Debug)] +enum FlowBlockPosition { + Paragraph { boundary_pos: usize }, + Object { anchor_pos: usize }, +} + +#[derive(Clone, Debug)] +struct ParagraphTextImportPlan { + boundary_pos: usize, +} + +#[derive(Clone, Debug)] +struct FlowTextImportPlan { + delta: Vec, + unicode_len: usize, + block_positions: Vec, + paragraphs: Vec, +} + +impl FlowTextImportPlan { + fn new(block_capacity: usize, delta_capacity: usize) -> Self { + let mut delta = Vec::with_capacity(delta_capacity.max(block_capacity.saturating_add(1))); + delta.push(TextDelta::Insert { + insert: "\n".to_string(), + attributes: Some(paragraph_style_attributes(ParagraphStyle::Normal)), + }); + Self { + delta, + unicode_len: 1, + block_positions: Vec::with_capacity(block_capacity), + paragraphs: Vec::new(), + } + } + + fn for_document(document: &DocumentProjection) -> Self { + let run_count = document + .paragraphs + .iter() + .map(|paragraph| paragraph.runs.len()) + .sum::(); + let mut plan = Self::new( + document.blocks.len(), + run_count + .saturating_add(document.blocks.len()) + .saturating_add(1), + ); + let mut paragraph_ix = 0_usize; + for block in document.blocks.iter() { + match block { + Block::Paragraph(paragraph) => { + let paragraph_body = paragraph_text(document, paragraph_ix); + plan.push_paragraph(paragraph, ¶graph_body); + paragraph_ix += 1; + }, + Block::Image(_) | Block::Equation(_) | Block::Table(_) => plan.push_object(), + } + } + plan + } + + fn push_paragraph(&mut self, paragraph: &Paragraph, paragraph_body: &str) { + let boundary_pos = if self.block_positions.is_empty() { + self.set_initial_paragraph_style(paragraph.style); + 0 + } else { + let boundary_pos = self.unicode_len; + push_rich_text_insert(&mut self.delta, "\n", Some(paragraph_style_attributes(paragraph.style))); + self.unicode_len += 1; + boundary_pos + }; + + self.push_paragraph_body(paragraph_body, ¶graph.runs); + self + .paragraphs + .push(ParagraphTextImportPlan { boundary_pos }); + self + .block_positions + .push(FlowBlockPosition::Paragraph { boundary_pos }); + } + + fn set_initial_paragraph_style(&mut self, style: ParagraphStyle) { + let Some(TextDelta::Insert { insert, attributes }) = self.delta.first_mut() else { + unreachable!("flow import plan always starts with a sentinel newline"); + }; + debug_assert_eq!(insert, "\n"); + *attributes = Some(paragraph_style_attributes(style)); + } + + fn push_paragraph_body(&mut self, paragraph_body: &str, runs: &[gpui_flowtext::TextRun]) { + let mut byte_offset = 0_usize; + for run in runs { + let byte_end = byte_offset.saturating_add(run.len); + if byte_end > paragraph_body.len() || !paragraph_body.is_char_boundary(byte_offset) || !paragraph_body.is_char_boundary(byte_end) { + break; + } + push_rich_text_insert(&mut self.delta, ¶graph_body[byte_offset..byte_end], run_style_attributes(run.styles)); + byte_offset = byte_end; + } + if byte_offset < paragraph_body.len() && paragraph_body.is_char_boundary(byte_offset) { + push_rich_text_insert(&mut self.delta, ¶graph_body[byte_offset..], None); + } + self.unicode_len += paragraph_body.chars().count(); + } + + fn push_object(&mut self) { + let anchor_pos = self.unicode_len; + let object = OBJECT_REPLACEMENT.to_string(); + push_rich_text_insert(&mut self.delta, &object, None); + self.unicode_len += 1; + self + .block_positions + .push(FlowBlockPosition::Object { anchor_pos }); + } + + fn write_to(&self, text: &LoroText) -> LoroResult<()> { + let len = text.len_unicode(); + if len > 0 { + text.delete(0, len)?; + } + // Import text and its complete semantic mark state as one rich-text batch. + text.apply_delta(&self.delta) + } +} + +fn paragraph_style_attributes(style: ParagraphStyle) -> FxHashMap { + let mut attributes = FxHashMap::default(); + attributes.insert(MARK_PARAGRAPH_STYLE.to_string(), paragraph_style_value(style).into()); + attributes +} + +fn run_style_attributes(styles: RunStyles) -> Option> { + let mut attributes = FxHashMap::default(); + if let RunSemanticStyle::Custom(slot) = styles.semantic { + attributes.insert(MARK_RUN_SEMANTIC_STYLE.to_string(), i64::from(slot).into()); + } + if let Some(HighlightStyle::Custom(slot)) = styles.highlight { + attributes.insert(MARK_HIGHLIGHT_STYLE.to_string(), i64::from(slot).into()); + } + if styles.direct_underline { + attributes.insert(MARK_DIRECT_UNDERLINE.to_string(), true.into()); + } + if styles.strikethrough { + attributes.insert(MARK_STRIKETHROUGH.to_string(), true.into()); + } + (!attributes.is_empty()).then_some(attributes) +} + +fn push_rich_text_insert(delta: &mut Vec, value: &str, attributes: Option>) { + if value.is_empty() { + return; + } + if let Some(TextDelta::Insert { + insert, + attributes: previous_attributes, + }) = delta.last_mut() + && previous_attributes.as_ref() == attributes.as_ref() + { + insert.push_str(value); + return; + } + delta.push(TextDelta::Insert { + insert: value.to_string(), + attributes, + }); +} + +fn import_table(flows: &LoroMap, block: &LoroMap, table: &TableBlock, prefix: &str) -> LoroResult<()> { + let table_map = block.ensure_mergeable_map("table")?; + let row_order = table_map.ensure_mergeable_movable_list("row_order")?; + let column_order = table_map.ensure_mergeable_movable_list("column_order")?; + let rows_by_id = table_map.ensure_mergeable_map("rows_by_id")?; + let columns_by_id = table_map.ensure_mergeable_map("columns_by_id")?; + let cells_by_id = table_map.ensure_mergeable_map("cells_by_id")?; + table_map.insert("container_id", table_map.id().to_string())?; + table_map.insert("row_order_container_id", row_order.id().to_string())?; + table_map.insert("column_order_container_id", column_order.id().to_string())?; + table_map.insert("rows_container_id", rows_by_id.id().to_string())?; + table_map.insert("columns_container_id", columns_by_id.id().to_string())?; + table_map.insert("cells_container_id", cells_by_id.id().to_string())?; + table_map.insert("header_row", table.style.header_row)?; + + clear_movable_list(&row_order)?; + clear_movable_list(&column_order)?; + clear_map(&rows_by_id)?; + clear_map(&columns_by_id)?; + clear_map(&cells_by_id)?; + + let column_count = table.column_widths.len().max( + table + .rows + .iter() + .map(|row| { + row + .cells + .iter() + .map(|cell| usize::from(cell.col_span.max(1))) + .sum() + }) + .max() + .unwrap_or(0), + ); + let mut column_ids = Vec::with_capacity(column_count); + for column_ix in 0..column_count { + let column_id = format!("{prefix}.column.{column_ix}"); + column_order.push(column_id.as_str())?; + column_ids.push(column_id.clone()); + let column = columns_by_id.ensure_mergeable_map(&column_id)?; + column.insert("id", column_id.as_str())?; + column.insert("container_id", column.id().to_string())?; + let attrs = column.ensure_mergeable_map("attrs")?; + column.insert("attrs_container_id", attrs.id().to_string())?; + match table.column_widths.get(column_ix) { + Some(TableColumnWidth::Auto) | None => column.insert("width_kind", "auto")?, + Some(TableColumnWidth::FixedPx(px)) => { + column.insert("width_kind", "fixed_px")?; + column.insert("width_px", i64::from(*px))?; + }, + Some(TableColumnWidth::Fraction(fraction)) => { + column.insert("width_kind", "fraction")?; + column.insert("fraction", i64::from(*fraction))?; + }, + }; + } + + for (row_ix, row) in table.rows.iter().enumerate() { + let row_id = format!("{prefix}.row.{row_ix}"); + row_order.push(row_id.as_str())?; + let row_map = rows_by_id.ensure_mergeable_map(&row_id)?; + row_map.insert("id", row_id.as_str())?; + row_map.insert("container_id", row_map.id().to_string())?; + let attrs = row_map.ensure_mergeable_map("attrs")?; + row_map.insert("attrs_container_id", attrs.id().to_string())?; + let mut column_ix = 0_usize; + for (cell_ix, cell) in row.cells.iter().enumerate() { + let cell_id = format!("{row_id}.cell.{cell_ix}"); + let column_id = &column_ids[column_ix]; + let cell_map = cells_by_id.ensure_mergeable_map(&cell_id)?; + cell_map.insert("id", cell_id.as_str())?; + cell_map.insert("container_id", cell_map.id().to_string())?; + cell_map.insert("row_id", row_id.as_str())?; + cell_map.insert("column_id", column_id.as_str())?; + cell_map.insert("row_span", i64::from(cell.row_span))?; + cell_map.insert("column_span", i64::from(cell.col_span))?; + let attrs = cell_map.ensure_mergeable_map("attrs")?; + cell_map.insert("attrs_container_id", attrs.id().to_string())?; + let flow_id = format!("{cell_id}.flow"); + cell_map.insert("flow_id", flow_id.as_str())?; + let nested_table_ids = cell_map.ensure_mergeable_movable_list("nested_table_ids")?; + let nested_tables_by_id = cell_map.ensure_mergeable_map("nested_tables_by_id")?; + cell_map.insert("nested_table_order_container_id", nested_table_ids.id().to_string())?; + cell_map.insert("nested_tables_container_id", nested_tables_by_id.id().to_string())?; + clear_movable_list(&nested_table_ids)?; + clear_map(&nested_tables_by_id)?; + let flow = ensure_flow(flows, &flow_id, "table_cell")?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + cell_map.insert("flow_container_id", flow.id().to_string())?; + cell_map.insert("text_container_id", text.id().to_string())?; + let cell_delta_capacity = cell + .blocks + .iter() + .map(|block| match block { + TableCellBlock::Paragraph(paragraph) => paragraph.paragraph.runs.len().saturating_add(1), + TableCellBlock::Table(_) => 1, + }) + .sum(); + let mut cell_plan = FlowTextImportPlan::new(cell.blocks.len(), cell_delta_capacity); + for cell_block in &cell.blocks { + match cell_block { + TableCellBlock::Paragraph(paragraph) => cell_plan.push_paragraph(¶graph.paragraph, ¶graph.text), + TableCellBlock::Table(_) => cell_plan.push_object(), + } + } + cell_plan.write_to(&text)?; + for (block_ix, (cell_block, position)) in cell + .blocks + .iter() + .zip(&cell_plan.block_positions) + .enumerate() + { + let (TableCellBlock::Table(nested), FlowBlockPosition::Object { anchor_pos }) = (cell_block, position) else { + continue; + }; + let nested_table_id = format!("{cell_id}.nested_table.{block_ix}"); + nested_table_ids.push(nested_table_id.as_str())?; + let nested_map = nested_tables_by_id.ensure_mergeable_map(&nested_table_id)?; + nested_map.insert("id", nested_table_id.as_str())?; + nested_map.insert("container_id", nested_map.id().to_string())?; + nested_map.insert("kind", "table")?; + if let Some(cursor) = text.get_cursor(*anchor_pos, Side::Left) { + nested_map.insert("anchor_cursor", cursor.encode())?; + } + nested_map.ensure_mergeable_map("attrs")?; + import_table(flows, &nested_map, nested, &format!("{cell_id}.nested.{block_ix}"))?; + } + column_ix += usize::from(cell.col_span.max(1)); + } + } + Ok(()) +} + +pub fn import_assets(doc: &LoroDoc, document: &DocumentProjection) -> LoroResult<()> { + let root = doc.get_map(ROOT); + let assets = root.ensure_mergeable_map(ASSETS_BY_ID)?; + clear_map(&assets)?; + for asset in document.assets.assets.values() { + let asset_id = asset.asset_id_string(); + let asset_map = assets.ensure_mergeable_map(&asset_id)?; + let hash = blake3::hash(&asset.bytes); + asset_map.insert("asset_id", asset_id.as_str())?; + asset_map.insert("container_id", asset_map.id().to_string())?; + asset_map.insert("content_hash", hash.to_hex().as_str())?; + asset_map.insert("mime_type", asset.mime_type.as_ref())?; + asset_map.insert("byte_length", i64::try_from(asset.bytes.len()).unwrap_or(i64::MAX))?; + if let Some((width, height)) = image_dimensions(asset.mime_type.as_ref(), &asset.bytes) { + // §31/§14: canonical AssetMap dimensions, stored as two i64 keys when derivable. + asset_map.insert("dimension_width", width)?; + asset_map.insert("dimension_height", height)?; + } + if let Some(original_name) = &asset.original_name { + asset_map.insert("original_name", original_name.as_ref())?; + } + } + Ok(()) +} + +/// Derive an image asset's intrinsic pixel dimensions from its bytes. +/// +/// Returns `None` for non-image assets or when the size cannot be determined, +/// in which case the asset map simply omits its `dimension_*` keys (§14). +fn image_dimensions(mime_type: &str, bytes: &[u8]) -> Option<(i64, i64)> { + if !mime_type.starts_with("image/") { + return None; + } + let size = imagesize::blob_size(bytes).ok()?; + Some((i64::try_from(size.width).ok()?, i64::try_from(size.height).ok()?)) +} + +pub fn assets_from_document(document: &DocumentProjection) -> Vec { + document + .assets + .assets + .values() + .map(|asset| AssetChunk { + asset_id: asset.id.0, + content_hash: *blake3::hash(&asset.bytes).as_bytes(), + mime_type: asset.mime_type.to_string(), + byte_length: asset.bytes.len() as u64, + bytes: Vec::clone(&asset.bytes), + metadata: Vec::new(), + }) + .collect() +} + +trait AssetRecordExt { + fn asset_id_string(&self) -> String; +} + +impl AssetRecordExt for gpui_flowtext::AssetRecord { + fn asset_id_string(&self) -> String { + self.id.0.to_string() + } +} + +fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult { + let flow = flows.ensure_mergeable_map(flow_id)?; + flow.insert(FLOW_ID_KEY, flow_id)?; + flow.insert(FLOW_KIND_KEY, kind)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let attrs = flow.ensure_mergeable_map(FLOW_ATTRS_KEY)?; + flow.insert("container_id", flow.id().to_string())?; + flow.insert("text_container_id", text.id().to_string())?; + flow.insert("attrs_container_id", attrs.id().to_string())?; + Ok(flow) +} + +fn ensure_block(blocks: &LoroMap, block_id: String, kind: &str, flow_id: &str, text: &LoroText, pos: usize) -> LoroResult { + let block = blocks.ensure_mergeable_map(&block_id)?; + block.insert("id", block_id.as_str())?; + block.insert("container_id", block.id().to_string())?; + block.insert("kind", kind)?; + block.insert("flow_id", flow_id)?; + if let Some(cursor) = text.get_cursor(pos, Side::Left) { + block.insert("anchor_cursor", cursor.encode())?; + } + let attrs = block.ensure_mergeable_map("attrs")?; + let nested_refs = block.ensure_mergeable_map("nested_refs")?; + block.insert("attrs_container_id", attrs.id().to_string())?; + block.insert("nested_refs_container_id", nested_refs.id().to_string())?; + Ok(block) +} + +fn replace_text(text: &LoroText, value: &str) -> LoroResult<()> { + let len = text.len_unicode(); + if len > 0 { + text.delete(0, len)?; + } + if !value.is_empty() { + text.insert(0, value)?; + } + Ok(()) +} + +fn clear_map(map: &LoroMap) -> LoroResult<()> { + // §29: use Loro's native container clear instead of deleting keys one-by-one. + map.clear() +} + +fn clear_movable_list(list: &LoroMovableList) -> LoroResult<()> { + list.clear() +} + +fn paragraph_style_value(style: ParagraphStyle) -> i64 { + match style { + ParagraphStyle::Normal => 0, + ParagraphStyle::Custom(slot) => i64::from(slot) + 1, + } +} + +fn projection_block_id(document: &DocumentProjection, block_ix: usize, kind: &str) -> String { + document + .ids + .block_ids + .get(block_ix) + .map_or_else(|| fallback_id(kind, block_ix), |id| format!("{kind}.{}", id.0)) +} + +fn projection_paragraph_id(document: &DocumentProjection, paragraph_ix: usize) -> String { + document + .ids + .paragraph_ids + .get(paragraph_ix) + .map_or_else(|| fallback_id("paragraph", paragraph_ix), |id| format!("paragraph.{}", id.0)) +} + +fn fallback_id(kind: &str, ix: usize) -> String { + format!( + "{kind}.{ix}.{}", + Uuid::new_v5(&Uuid::NAMESPACE_OID, format!("{kind}.{ix}").as_bytes()).as_u128() + ) +} + +fn nested_flow_id(kind: &str, block_id: &str) -> String { + format!("{block_id}.{kind}") +} + +fn alignment_name(alignment: BlockAlignment) -> &'static str { + match alignment { + BlockAlignment::Left => "left", + BlockAlignment::Center => "center", + BlockAlignment::Right => "right", + } +} + +fn equation_syntax_name(syntax: EquationSyntax) -> &'static str { + match syntax { + EquationSyntax::Latex => "latex", + } +} + +fn equation_display_name(display: EquationDisplay) -> &'static str { + match display { + EquationDisplay::Display => "display", + EquationDisplay::InlineLikeParagraph => "inline_like_paragraph", + } +} + +fn loro_io_error(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_identity_round_trips_into_loro() -> io::Result<()> { + let mut source = gpui_flowtext::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![gpui_flowtext::InputBlock::Paragraph(gpui_flowtext::InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![gpui_flowtext::InputRun { + text: "identity".to_string(), + styles: RunStyles::default(), + }], + })], + ); + source.ids.document_id = 0x0123; + source.ids.paragraph_ids[0] = gpui_flowtext::ParagraphId(0x0456); + source.ids.block_ids[0] = gpui_flowtext::BlockId(0x0789); + + let doc = document_to_loro(&source, "Identity")?; + let projected = crate::document_from_loro(&doc)?; + + assert_eq!(projected.ids.document_id, source.ids.document_id); + assert_eq!(projected.ids.paragraph_ids, source.ids.paragraph_ids); + assert_eq!(projected.ids.block_ids, source.ids.block_ids); + Ok(()) + } + + #[test] + fn custom_paragraph_style_slot_zero_round_trips() -> io::Result<()> { + let source = gpui_flowtext::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![gpui_flowtext::InputBlock::Paragraph(gpui_flowtext::InputParagraph { + style: ParagraphStyle::Custom(0), + runs: vec![gpui_flowtext::InputRun { + text: "pocket".to_string(), + styles: RunStyles::default(), + }], + })], + ); + + let doc = document_to_loro(&source, "Pocket")?; + let projected = crate::document_from_loro(&doc)?; + assert_eq!(projected.paragraphs[0].style, ParagraphStyle::Custom(0)); + Ok(()) + } + + #[test] + fn bulk_import_preserves_empty_paragraphs_unicode_and_frontier() -> io::Result<()> { + let paragraphs = vec![ + DocumentParagraphInput { + style: ParagraphStyle::Custom(0), + runs: Vec::new(), + }, + DocumentParagraphInput { + style: ParagraphStyle::Normal, + runs: Vec::new(), + }, + DocumentParagraphInput { + style: ParagraphStyle::Custom(2), + runs: vec![gpui_flowtext::DocumentRunInput { + text: "héllo 世界".to_string(), + styles: RunStyles { + semantic: RunSemanticStyle::Custom(3), + direct_underline: true, + ..RunStyles::default() + }, + }], + }, + ]; + + let imported = import_paragraphs_as_loro(DocumentTheme::default(), paragraphs, "Bulk import")?; + assert_eq!(imported.projection.frontier, imported.doc.state_frontiers().encode()); + assert_eq!(crate::loro_schema::body_text(&imported.doc).to_string(), "\n\n\nhéllo 世界"); + + let projected = crate::document_from_loro(&imported.doc)?; + assert_eq!(projected.paragraphs.len(), 3); + assert_eq!(paragraph_text(&projected, 0), ""); + assert_eq!(paragraph_text(&projected, 1), ""); + assert_eq!(paragraph_text(&projected, 2), "héllo 世界"); + assert_eq!(projected.paragraphs[0].style, ParagraphStyle::Custom(0)); + assert_eq!(projected.paragraphs[2].style, ParagraphStyle::Custom(2)); + assert_eq!(projected.paragraphs[2].runs[0].styles.semantic, RunSemanticStyle::Custom(3)); + assert!(projected.paragraphs[2].runs[0].styles.direct_underline); + Ok(()) + } + + #[test] + fn bulk_delta_import_preserves_all_semantic_run_attributes() -> io::Result<()> { + let expected = RunStyles { + semantic: RunSemanticStyle::Custom(3), + direct_underline: true, + strikethrough: true, + highlight: Some(HighlightStyle::Custom(4)), + }; + let imported = import_paragraphs_as_loro( + crate::flowstate_document_theme(), + vec![DocumentParagraphInput { + style: ParagraphStyle::Custom(2), + runs: vec![ + gpui_flowtext::DocumentRunInput { + text: "styled".to_string(), + styles: expected, + }, + gpui_flowtext::DocumentRunInput { + text: " plain".to_string(), + styles: RunStyles::default(), + }, + ], + }], + "Rich delta import", + )?; + + let projected = crate::document_from_loro(&imported.doc)?; + assert_eq!(paragraph_text(&projected, 0), "styled plain"); + assert_eq!(projected.paragraphs[0].style, ParagraphStyle::Custom(2)); + assert!( + projected.paragraphs[0] + .runs + .iter() + .any(|run| run.styles == expected) + ); + Ok(()) + } + + #[test] + fn bulk_import_handles_large_paragraph_sets_without_reprojection() -> io::Result<()> { + let paragraphs = (0..2_000) + .map(|ix| DocumentParagraphInput { + style: if ix % 11 == 0 { + ParagraphStyle::Custom(1) + } else { + ParagraphStyle::Normal + }, + runs: vec![gpui_flowtext::DocumentRunInput { + text: format!("paragraph {ix}"), + styles: RunStyles::default(), + }], + }) + .collect(); + + let imported = import_paragraphs_as_loro(DocumentTheme::default(), paragraphs, "Large import")?; + assert_eq!(imported.projection.paragraphs.len(), 2_000); + assert_eq!(imported.projection.frontier, imported.doc.state_frontiers().encode()); + assert_eq!(paragraph_text(&imported.projection, 1_999), "paragraph 1999"); + Ok(()) + } + + #[test] + fn section_page_attrs_round_trip_through_import_write_path() -> io::Result<()> { + let mut source = gpui_flowtext::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![gpui_flowtext::InputBlock::Paragraph(gpui_flowtext::InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![gpui_flowtext::InputRun { + text: "section body".to_string(), + styles: RunStyles::default(), + }], + })], + ); + // §11: non-default page structure on the source section must survive the + // import write path and be projected back identically. + let page = gpui_flowtext::SectionPageAttrs { + page_size: gpui_flowtext::SectionPageSize { + width_twips: 15_840, + height_twips: 12_240, + }, + margins: gpui_flowtext::SectionMargins { + top_twips: 720, + right_twips: 720, + bottom_twips: 1_440, + left_twips: 1_440, + }, + columns: 2, + orientation: gpui_flowtext::SectionOrientation::Landscape, + page_numbering: gpui_flowtext::SectionPageNumbering { + format: gpui_flowtext::PageNumberFormat::LowerRoman, + start: 3, + }, + header_flow_id: Some("section.s1.header".to_string()), + footer_flow_id: Some("section.s1.footer".to_string()), + }; + source.sections = std::sync::Arc::new(vec![gpui_flowtext::DocumentSection { + id: gpui_flowtext::SectionId(0x5ec1), + parent_id: None, + kind: gpui_flowtext::SectionKind::Custom(0), + heading_paragraph: None, + start_paragraph: source.ids.paragraph_ids[0], + end_paragraph_exclusive: None, + page: Some(page.clone()), + }]); + + let doc = document_to_loro(&source, "Section page attrs")?; + let projected = crate::document_from_loro(&doc)?; + + assert_eq!(projected.sections.len(), 1); + assert_eq!(projected.sections[0].page, Some(page)); + Ok(()) + } +} diff --git a/crates/flowstate-document/src/loro_projection.rs b/crates/flowstate-document/src/loro_projection.rs new file mode 100644 index 00000000..bb37759d --- /dev/null +++ b/crates/flowstate-document/src/loro_projection.rs @@ -0,0 +1,1025 @@ +use std::{collections::BTreeMap, io, sync::Arc}; + +use gpui_flowtext::{ + AssetId, BlockId, DocumentProjection, DocumentSection, DocumentTheme, HighlightStyle, InputBlock, InputBlockAlignment, InputEquationBlock, + InputEquationDisplay, InputEquationSyntax, InputImageBlock, InputImageSizing, InputParagraph, InputRun, InputTableBlock, InputTableCell, + InputTableCellBlock, InputTableColumnWidth, InputTableRow, InputTableStyle, ParagraphId, RunSemanticStyle, RunStyles, SectionId, SectionKind, + document_from_input_blocks, +}; +use loro::{Container, ContainerID, ContainerTrait, LoroDoc, LoroMap, LoroText, LoroValue, ValueOrContainer, cursor::Cursor}; +use rustc_hash::FxHashMap; + +use crate::{ + BLOCKS_BY_ID, FLOW_TEXT_KEY, FLOWS_BY_ID, MAIN_BODY_BLOCK_ID, MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, + MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, OBJECT_REPLACEMENT, PARAGRAPHS_BY_ID, ROOT, ROOT_BODY_FLOW_ID, ROOT_FIRST_PARAGRAPH_ID, + SECTIONS_BY_ID, flowstate_document_theme, +}; + +pub fn document_from_loro(doc: &LoroDoc) -> io::Result { + let projection = projection_from_loro(doc)?; + let mut document = document_from_projection_blocks(projection); + document.frontier = doc.state_frontiers().encode(); + Ok(document) +} + +pub(crate) fn input_blocks_from_loro(doc: &LoroDoc) -> io::Result> { + Ok(projection_from_loro(doc)?.blocks) +} + +pub fn object_input_blocks_from_loro(doc: &LoroDoc) -> io::Result> { + let projector = Projector::new(doc)?; + let mut blocks = Vec::new(); + for key in projector.blocks.keys().map(|key| key.to_string()) { + let Some(block) = child_map(&projector.blocks, &key)? else { + continue; + }; + if map_string_opt(&block, "kind")?.as_deref() == Some("paragraph") { + continue; + } + let id = map_string_opt(&block, "id")?.unwrap_or(key); + blocks.push((BlockId(loro_id_u128(&id)), projector.object_block(&block)?)); + } + blocks.sort_by_key(|(id, _)| id.0); + Ok(blocks) +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct ProjectionBlocks { + #[serde(default)] + pub document_id: u128, + pub blocks: Vec, + pub paragraph_ids: Vec, + pub block_ids: Vec, + #[serde(default)] + pub sections: Vec, +} + +fn projection_from_loro(doc: &LoroDoc) -> io::Result { + let projector = Projector::new(doc)?; + projector.body_projection() +} + +pub(crate) fn projection_blocks_from_loro(doc: &LoroDoc) -> io::Result { + projection_from_loro(doc) +} + +pub(crate) fn document_from_projection_blocks(projection: ProjectionBlocks) -> DocumentProjection { + let mut document = document_from_input_blocks(DocumentTheme::clone(&flowstate_document_theme()), projection.blocks); + if projection.document_id != 0 { + document.ids.document_id = projection.document_id; + } + if projection.paragraph_ids.len() == document.paragraphs.len() { + document.ids.paragraph_ids = projection.paragraph_ids; + } + if projection.block_ids.len() == document.blocks.len() { + document.ids.block_ids = projection.block_ids; + } + if !projection.sections.is_empty() { + document.sections = Arc::new(projection.sections); + } + document +} + +struct Projector<'a> { + doc: &'a LoroDoc, + flows: LoroMap, + blocks: LoroMap, +} + +impl<'a> Projector<'a> { + fn new(doc: &'a LoroDoc) -> io::Result { + let root = doc.get_map(ROOT); + let flows = child_map(&root, FLOWS_BY_ID)?.ok_or_else(|| invalid("Flowstate Loro package has no flows map"))?; + let blocks = child_map(&root, BLOCKS_BY_ID)?.ok_or_else(|| invalid("Flowstate Loro package has no block registry"))?; + Ok(Self { doc, flows, blocks }) + } + + fn body_projection(&self) -> io::Result { + let body = self.flow_text(ROOT_BODY_FLOW_ID)?; + let body_blocks = self.object_blocks_for_flow(ROOT_BODY_FLOW_ID)?; + let mut blocks = Vec::new(); + let mut paragraph_ids = Vec::new(); + let mut block_ids = Vec::new(); + self.push_flow_blocks(&body, &body_blocks, Some(&mut paragraph_ids), Some(&mut block_ids), &mut blocks)?; + if blocks.is_empty() { + blocks.push(InputBlock::Paragraph(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + })); + paragraph_ids.push(ParagraphId(loro_id_u128("paragraph.projection.empty"))); + block_ids.push(BlockId(loro_id_u128("block.projection.empty"))); + } + let sections = self.sections_for_projection(¶graph_ids)?; + Ok(ProjectionBlocks { + document_id: crate::loro_schema::document_id(self.doc).map_or(0, |id| id.as_u128()), + blocks, + paragraph_ids, + block_ids, + sections, + }) + } + + /// Project a `DocumentSection` for each Loro section, including its §11 page + /// structure (page size, margins, columns, orientation, page numbering, + /// header/footer flow ids). The canonical attrs live in Loro and are read + /// back via [`crate::loro_schema::read_section_page_attrs`] (which substitutes + /// documented defaults for missing keys), then mapped field-for-field onto the + /// gpui-flowtext projection mirror `gpui_flowtext::SectionPageAttrs`. + fn sections_for_projection(&self, paragraph_ids: &[ParagraphId]) -> io::Result> { + let root = self.doc.get_map(ROOT); + let Some(sections_by_id) = child_map(&root, SECTIONS_BY_ID)? else { + return Ok(Vec::new()); + }; + let paragraph_order = paragraph_ids + .iter() + .enumerate() + .map(|(ix, id)| (id.0, ix)) + .collect::>(); + let mut sections = Vec::new(); + for key in map_keys(§ions_by_id) { + let Some(section) = child_map(§ions_by_id, &key)? else { + continue; + }; + let Some(start_paragraph) = section_id_field(§ion, "start_paragraph_id")? else { + continue; + }; + let section_id = map_string_opt(§ion, "id")? + .and_then(|value| parse_u128(&value)) + .unwrap_or_else(|| loro_id_u128(&key)); + let kind_slot = map_i64_opt(§ion, "kind_slot")? + .and_then(i64_to_u8) + .unwrap_or(0); + // §11: read the section's canonical page attrs from its `attrs` child map + // (defaults substituted for missing keys) and project them. The section + // map always exists here, so `page` is always `Some(..)` for determinism. + let canonical_page = match child_map(§ion, "attrs")? { + Some(attrs) => crate::loro_schema::read_section_page_attrs(&attrs), + None => crate::loro_schema::SectionPageAttrs::default(), + }; + sections.push(DocumentSection { + id: SectionId(section_id), + parent_id: section_id_field(§ion, "parent_section_id")?.map(SectionId), + kind: SectionKind::Custom(kind_slot), + heading_paragraph: section_id_field(§ion, "heading_paragraph_id")?.map(ParagraphId), + start_paragraph: ParagraphId(start_paragraph), + end_paragraph_exclusive: section_id_field(§ion, "end_paragraph_exclusive_id")?.map(ParagraphId), + page: Some(project_section_page_attrs(canonical_page)), + }); + } + sections.sort_by_key(|section| { + paragraph_order + .get(§ion.start_paragraph.0) + .copied() + .unwrap_or(usize::MAX) + }); + Ok(sections) + } + + fn push_flow_blocks( + &self, + text: &LoroText, + object_blocks: &BTreeMap, + mut paragraph_ids: Option<&mut Vec>, + mut block_ids: Option<&mut Vec>, + output: &mut Vec, + ) -> io::Result<()> { + let mut current = InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + }; + let mut pending_style = gpui_flowtext::ParagraphStyle::Normal; + let mut seen_sentinel = false; + let mut unicode_pos = 0_usize; + let mut current_boundary = None; + + for item in text.to_delta() { + let loro::TextDelta::Insert { insert, attributes } = item else { + continue; + }; + let run_styles = run_styles_from_attrs(attributes.as_ref()); + for ch in insert.chars() { + match ch { + '\n' => { + let style = paragraph_style_from_attrs(attributes.as_ref()).unwrap_or(pending_style); + if !seen_sentinel { + seen_sentinel = true; + pending_style = style; + current.style = style; + current_boundary = Some(unicode_pos); + } else if current.runs.is_empty() + && output + .last() + .is_some_and(|block| !matches!(block, InputBlock::Paragraph(_))) + { + current.style = style; + pending_style = style; + current_boundary = Some(unicode_pos); + } else { + push_paragraph_projection_metadata( + self.doc, + text, + current_boundary, + output.len(), + paragraph_ids.as_deref_mut(), + block_ids.as_deref_mut(), + ); + output.push(InputBlock::Paragraph(current)); + current = InputParagraph { style, runs: Vec::new() }; + pending_style = style; + current_boundary = Some(unicode_pos); + } + }, + OBJECT_REPLACEMENT => { + if let Some(block) = object_blocks.get(&unicode_pos) { + if !current.runs.is_empty() { + push_paragraph_projection_metadata( + self.doc, + text, + current_boundary, + output.len(), + paragraph_ids.as_deref_mut(), + block_ids.as_deref_mut(), + ); + output.push(InputBlock::Paragraph(current)); + current = InputParagraph { + style: pending_style, + runs: Vec::new(), + }; + } + output.push(self.object_block(block)?); + if let Some(block_ids) = block_ids.as_deref_mut() { + block_ids.push(BlockId(loro_id_u128(&map_string(block, "id")?))); + } + current_boundary = None; + } + }, + _ => push_char(&mut current, ch, run_styles), + } + unicode_pos += 1; + } + } + + if !current.runs.is_empty() || current_boundary.is_some() || output.is_empty() && seen_sentinel { + push_paragraph_projection_metadata(self.doc, text, current_boundary, output.len(), paragraph_ids, block_ids); + output.push(InputBlock::Paragraph(current)); + } + Ok(()) + } + + fn object_blocks_for_flow(&self, flow_id: &str) -> io::Result> { + let mut by_pos = BTreeMap::new(); + for key in self.blocks.keys() { + let key = key.to_string(); + let Some(block) = child_map(&self.blocks, &key)? else { + continue; + }; + if map_string_opt(&block, "flow_id")?.as_deref() != Some(flow_id) { + continue; + } + if map_string_opt(&block, "kind")?.as_deref() == Some("paragraph") { + continue; + } + let Some(cursor_bytes) = map_binary_opt(&block, "anchor_cursor")? else { + continue; + }; + let Ok(cursor) = Cursor::decode(&cursor_bytes) else { + continue; + }; + if let Ok(pos) = self.doc.get_cursor_pos(&cursor) { + by_pos.insert(pos.current.pos, block); + } + } + Ok(by_pos) + } + + fn object_block(&self, block: &LoroMap) -> io::Result { + match map_string(block, "kind")?.as_str() { + "image" => self.image_block(block).map(InputBlock::Image), + "equation" => self.equation_block(block).map(InputBlock::Equation), + "table" => self.table_block(block).map(InputBlock::Table), + kind => Err(invalid(format!("unsupported Loro block kind `{kind}`"))), + } + } + + fn image_block(&self, block: &LoroMap) -> io::Result { + let attrs = child_map(block, "attrs")?; + Ok(InputImageBlock { + asset_id: AssetId(parse_u128(&map_string(block, "asset_id")?).ok_or_else(|| invalid("image block has invalid asset_id"))?), + alt_text: map_string_opt(block, "alt_text_flow_id")? + .map(|flow_id| self.plain_flow_text(&flow_id)) + .transpose()? + .unwrap_or_default(), + caption: map_string_opt(block, "caption_flow_id")? + .map(|flow_id| self.caption_paragraph(&flow_id)) + .transpose()?, + sizing: image_sizing(attrs.as_ref())?, + alignment: alignment(attrs.as_ref())?, + }) + } + + fn equation_block(&self, block: &LoroMap) -> io::Result { + let attrs = child_map(block, "attrs")?; + Ok(InputEquationBlock { + source: map_string_opt(block, "source_flow_id")? + .map(|flow_id| self.plain_flow_text(&flow_id)) + .transpose()? + .unwrap_or_default(), + syntax: equation_syntax(attrs.as_ref())?, + display: equation_display(attrs.as_ref())?, + }) + } + + fn table_block(&self, owner: &LoroMap) -> io::Result { + let table = child_map(owner, "table")?.ok_or_else(|| invalid("table block has no table map"))?; + self.table_from_map(&table) + } + + fn table_from_map(&self, table: &LoroMap) -> io::Result { + // §28: resolve the table's child containers through their stored raw + // container ids, falling back to key traversal when unavailable. + let columns = self + .resolve_child_map(table, "columns_container_id", "columns_by_id")? + .ok_or_else(|| invalid("table has no column map"))?; + let rows_by_id = self + .resolve_child_map(table, "rows_container_id", "rows_by_id")? + .ok_or_else(|| invalid("table has no row map"))?; + let cells_by_id = self + .resolve_child_map(table, "cells_container_id", "cells_by_id")? + .ok_or_else(|| invalid("table has no cell map"))?; + let column_ids = ordered_ids(table, "column_order")?; + let column_positions = column_ids + .iter() + .enumerate() + .map(|(ix, column_id)| (column_id.clone(), ix)) + .collect::>(); + let column_widths = column_ids + .iter() + .map(|column_id| { + let column = child_map(&columns, column_id)?.ok_or_else(|| invalid(format!("missing table column `{column_id}`")))?; + table_column_width(&column) + }) + .collect::>>()?; + + let mut rows = Vec::new(); + for row_id in ordered_ids(table, "row_order")? { + let _row = child_map(&rows_by_id, &row_id)?.ok_or_else(|| invalid(format!("missing table row `{row_id}`")))?; + let mut row_cells = Vec::new(); + let mut cells_by_column = BTreeMap::new(); + for cell_id in cells_by_id.keys().map(|key| key.to_string()) { + let Some(cell) = child_map(&cells_by_id, &cell_id)? else { + continue; + }; + if map_string_opt(&cell, "row_id")?.as_deref() != Some(row_id.as_str()) { + continue; + } + let column_id = map_string(&cell, "column_id")?; + if let Some(column_ix) = column_positions.get(&column_id) { + cells_by_column.insert(*column_ix, cell); + } + } + for (_, cell) in cells_by_column { + row_cells.push(self.table_cell(&cell)?); + } + rows.push(InputTableRow { cells: row_cells }); + } + + Ok(InputTableBlock { + rows, + column_widths, + style: InputTableStyle { + header_row: map_bool_opt(table, "header_row")?.unwrap_or(false), + }, + }) + } + + fn table_cell(&self, cell: &LoroMap) -> io::Result { + let flow_id = map_string(cell, "flow_id")?; + let flow = self.flow_text(&flow_id)?; + let object_blocks = self.cell_nested_tables(cell, &flow)?; + let mut projected = Vec::new(); + self.push_flow_blocks(&flow, &object_blocks, None, None, &mut projected)?; + let mut blocks = projected + .into_iter() + .filter_map(|block| match block { + InputBlock::Paragraph(paragraph) => Some(Ok(InputTableCellBlock::Paragraph(paragraph))), + InputBlock::Table(table) => Some(Ok(InputTableCellBlock::Table(table))), + InputBlock::Image(_) | InputBlock::Equation(_) => None, + }) + .collect::>>()?; + if blocks.is_empty() { + blocks.push(InputTableCellBlock::Paragraph(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + })); + } + Ok(InputTableCell { + blocks, + row_span: map_i64_opt(cell, "row_span")? + .and_then(i64_to_u16) + .unwrap_or(1), + col_span: map_i64_opt(cell, "column_span")? + .and_then(i64_to_u16) + .unwrap_or(1), + }) + } + + fn cell_nested_tables(&self, cell: &LoroMap, flow: &LoroText) -> io::Result> { + let mut tables = BTreeMap::new(); + let Some(tables_by_id) = child_map(cell, "nested_tables_by_id")? else { + return Ok(tables); + }; + for nested_table_id in ordered_ids(cell, "nested_table_ids")? { + let Some(owner) = child_map(&tables_by_id, &nested_table_id)? else { + continue; + }; + let Some(cursor_bytes) = map_binary_opt(&owner, "anchor_cursor")? else { + continue; + }; + let Ok(cursor) = Cursor::decode(&cursor_bytes) else { + continue; + }; + if cursor.container != flow.id() { + continue; + } + if let Ok(pos) = self.doc.get_cursor_pos(&cursor) { + tables.insert(pos.current.pos, owner); + } + } + Ok(tables) + } + + fn flow_text(&self, flow_id: &str) -> io::Result { + let flow = child_map(&self.flows, flow_id)?.ok_or_else(|| invalid(format!("missing flow `{flow_id}`")))?; + // §28: prefer direct resolution via the flow's stored raw container id, and + // only fall back to map-key traversal when the id is missing/unresolvable. + if let Some(container_id) = map_string_opt(&flow, "text_container_id")? + && let Some(text) = resolve_text_by_container_id(self.doc, &container_id) + { + return Ok(text); + } + child_text(&flow, FLOW_TEXT_KEY)?.ok_or_else(|| invalid(format!("flow `{flow_id}` has no text"))) + } + + /// §28: resolve a child container map by its stored raw container id, falling + /// back to direct map-key traversal when the id is missing/unresolvable. + fn resolve_child_map(&self, owner: &LoroMap, container_id_key: &str, fallback_key: &str) -> io::Result> { + if let Some(container_id) = map_string_opt(owner, container_id_key)? + && let Some(map) = resolve_map_by_container_id(self.doc, &container_id) + { + return Ok(Some(map)); + } + child_map(owner, fallback_key) + } + + fn plain_flow_text(&self, flow_id: &str) -> io::Result { + let text = self.flow_text(flow_id)?.to_string(); + Ok(text.strip_prefix('\n').unwrap_or(text.as_str()).to_string()) + } + + fn caption_paragraph(&self, flow_id: &str) -> io::Result { + let paragraphs = paragraphs_from_text(&self.flow_text(flow_id)?); + Ok(paragraphs.into_iter().next().unwrap_or(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + })) + } +} + +fn paragraphs_from_text(text: &LoroText) -> Vec { + let mut blocks = Vec::new(); + let projector = ParagraphOnlyProjector; + projector.push_flow_blocks(text, &mut blocks); + blocks +} + +struct ParagraphOnlyProjector; + +impl ParagraphOnlyProjector { + fn push_flow_blocks(&self, text: &LoroText, output: &mut Vec) { + let mut current = InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + }; + let mut pending_style = gpui_flowtext::ParagraphStyle::Normal; + let mut seen_sentinel = false; + for item in text.to_delta() { + let loro::TextDelta::Insert { insert, attributes } = item else { + continue; + }; + let run_styles = run_styles_from_attrs(attributes.as_ref()); + for ch in insert.chars() { + if ch == '\n' { + let style = paragraph_style_from_attrs(attributes.as_ref()).unwrap_or(pending_style); + if !seen_sentinel { + seen_sentinel = true; + pending_style = style; + current.style = style; + } else { + output.push(current); + current = InputParagraph { style, runs: Vec::new() }; + pending_style = style; + } + } else if ch != OBJECT_REPLACEMENT { + push_char(&mut current, ch, run_styles); + } + } + } + if seen_sentinel || !current.runs.is_empty() { + output.push(current); + } + } +} + +fn push_char(paragraph: &mut InputParagraph, ch: char, styles: RunStyles) { + if let Some(last) = paragraph.runs.last_mut() + && last.styles == styles + { + last.text.push(ch); + return; + } + paragraph.runs.push(InputRun { + text: ch.to_string(), + styles, + }); +} + +fn push_paragraph_projection_metadata( + doc: &LoroDoc, + text: &LoroText, + boundary: Option, + block_ix: usize, + paragraph_ids: Option<&mut Vec>, + block_ids: Option<&mut Vec>, +) { + if let Some(paragraph_ids) = paragraph_ids { + let id = boundary + .and_then(|boundary| paragraph_loro_id_at_boundary(doc, text, boundary)) + .unwrap_or_else(|| format!("paragraph.projection.{block_ix}")); + paragraph_ids.push(ParagraphId(loro_id_u128(&id))); + } + if let Some(block_ids) = block_ids { + let id = boundary + .and_then(|boundary| paragraph_block_loro_id_at_boundary(doc, text, boundary)) + .unwrap_or_else(|| format!("paragraph_block.projection.{block_ix}")); + block_ids.push(BlockId(loro_id_u128(&id))); + } +} + +fn paragraph_loro_id_at_boundary(doc: &LoroDoc, text: &LoroText, boundary: usize) -> Option { + let root = doc.get_map(ROOT); + let paragraphs = child_map(&root, PARAGRAPHS_BY_ID).ok().flatten()?; + let mut matches = map_keys(¶graphs) + .into_iter() + .filter(|key| { + child_map(¶graphs, key) + .ok() + .flatten() + .and_then(|paragraph| { + live_cursor_pos(doc, text, ¶graph, "boundary_cursor").or_else(|| live_cursor_pos(doc, text, ¶graph, "start_cursor")) + }) + == Some(boundary) + }) + .collect::>(); + if boundary == 0 + && let Some(ix) = matches + .iter() + .position(|key| key == ROOT_FIRST_PARAGRAPH_ID) + { + return Some(matches.swap_remove(ix)); + } + matches.into_iter().next() +} + +fn paragraph_block_loro_id_at_boundary(doc: &LoroDoc, text: &LoroText, boundary: usize) -> Option { + let root = doc.get_map(ROOT); + let blocks = child_map(&root, BLOCKS_BY_ID).ok().flatten()?; + let mut matches = map_keys(&blocks) + .into_iter() + .filter(|key| { + let Some(block) = child_map(&blocks, key).ok().flatten() else { + return false; + }; + map_string_opt(&block, "kind").ok().flatten().as_deref() == Some("paragraph") + && live_cursor_pos(doc, text, &block, "anchor_cursor") == Some(boundary) + }) + .collect::>(); + if boundary == 0 + && let Some(ix) = matches.iter().position(|key| key == MAIN_BODY_BLOCK_ID) + { + return Some(matches.swap_remove(ix)); + } + matches.into_iter().next() +} + +fn live_cursor_pos(doc: &LoroDoc, text: &LoroText, map: &LoroMap, key: &str) -> Option { + let cursor_bytes = map_binary_opt(map, key).ok().flatten()?; + let cursor = Cursor::decode(&cursor_bytes).ok()?; + if cursor.container != text.id() { + return None; + } + let pos = doc.get_cursor_pos(&cursor).ok()?.current.pos; + (text.to_string().chars().nth(pos).is_some()).then_some(pos) +} + +fn map_keys(map: &LoroMap) -> Vec { + let mut keys = map.keys().map(|key| key.to_string()).collect::>(); + keys.sort(); + keys +} + +fn loro_id_u128(id: &str) -> u128 { + if let Some(value) = id + .rsplit('.') + .next() + .and_then(|suffix| suffix.parse::().ok()) + { + return value; + } + let hash = blake3::hash(id.as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&hash.as_bytes()[..16]); + u128::from_le_bytes(bytes) +} + +fn child_map(parent: &LoroMap, key: &str) -> io::Result> { + Ok(parent.get(key).and_then(|value| match value { + ValueOrContainer::Container(container) => container.into_map().ok(), + ValueOrContainer::Value(_) => None, + })) +} + +fn child_text(parent: &LoroMap, key: &str) -> io::Result> { + Ok(parent.get(key).and_then(|value| match value { + ValueOrContainer::Container(container) => container.into_text().ok(), + ValueOrContainer::Value(_) => None, + })) +} + +/// §28: centralized resolution of a stored raw Loro container id string. +/// +/// Parses the durable `*_container_id` string into a [`ContainerID`] and fetches +/// the live container directly from the document for efficient runtime access. +/// Returns `None` when the id is missing/unparseable or the container is +/// absent/detached/deleted, so callers can fall back to map-key traversal. +fn resolve_container(doc: &LoroDoc, container_id: &str) -> Option { + let container = doc.get_container(ContainerID::try_from(container_id).ok()?)?; + (container.is_attached() && !container.is_deleted()).then_some(container) +} + +fn resolve_map_by_container_id(doc: &LoroDoc, container_id: &str) -> Option { + resolve_container(doc, container_id)?.into_map().ok() +} + +fn resolve_text_by_container_id(doc: &LoroDoc, container_id: &str) -> Option { + resolve_container(doc, container_id)?.into_text().ok() +} + +fn ordered_ids(map: &LoroMap, key: &str) -> io::Result> { + let Some(ValueOrContainer::Container(container)) = map.get(key) else { + return Ok(Vec::new()); + }; + let value = match container { + Container::MovableList(list) => list.get_deep_value(), + _ => return Ok(Vec::new()), + }; + Ok( + value + .into_list() + .unwrap_or_default() + .iter() + .filter_map(|value| match value { + LoroValue::String(value) => Some(value.to_string()), + _ => None, + }) + .collect(), + ) +} + +fn map_string(map: &LoroMap, key: &str) -> io::Result { + map_string_opt(map, key)?.ok_or_else(|| invalid(format!("missing string field `{key}`"))) +} + +fn map_string_opt(map: &LoroMap, key: &str) -> io::Result> { + Ok(map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + })) +} + +fn map_binary_opt(map: &LoroMap, key: &str) -> io::Result>> { + Ok(map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::Binary(value)) => Some(value.to_vec()), + _ => None, + })) +} + +fn map_i64_opt(map: &LoroMap, key: &str) -> io::Result> { + Ok(map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::I64(value)) => Some(value), + _ => None, + })) +} + +fn map_bool_opt(map: &LoroMap, key: &str) -> io::Result> { + Ok(map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::Bool(value)) => Some(value), + _ => None, + })) +} + +fn paragraph_style_from_attrs(attrs: Option<&FxHashMap>) -> Option { + let value = attrs?.get(MARK_PARAGRAPH_STYLE)?; + match value { + LoroValue::I64(0) => Some(gpui_flowtext::ParagraphStyle::Normal), + LoroValue::I64(slot) if *slot > 0 => u8::try_from(*slot - 1) + .ok() + .map(gpui_flowtext::ParagraphStyle::Custom), + _ => None, + } +} + +fn run_styles_from_attrs(attrs: Option<&FxHashMap>) -> RunStyles { + let mut styles = RunStyles::default(); + let Some(attrs) = attrs else { + return styles; + }; + if let Some(LoroValue::I64(slot)) = attrs.get(MARK_RUN_SEMANTIC_STYLE) + && let Ok(slot) = u8::try_from(*slot) + { + styles.semantic = RunSemanticStyle::Custom(slot); + } + if let Some(LoroValue::I64(slot)) = attrs.get(MARK_HIGHLIGHT_STYLE) + && let Ok(slot) = u8::try_from(*slot) + { + styles.highlight = Some(HighlightStyle::Custom(slot)); + } + if matches!(attrs.get(MARK_DIRECT_UNDERLINE), Some(LoroValue::Bool(true))) { + styles.direct_underline = true; + } + if matches!(attrs.get(MARK_STRIKETHROUGH), Some(LoroValue::Bool(true))) { + styles.strikethrough = true; + } + styles +} + +fn image_sizing(attrs: Option<&LoroMap>) -> io::Result { + let Some(attrs) = attrs else { + return Ok(InputImageSizing::FitWidth); + }; + match map_string_opt(attrs, "sizing")?.as_deref() { + Some("intrinsic") => Ok(InputImageSizing::Intrinsic), + Some("fixed") => Ok(InputImageSizing::Fixed { + width_px: map_i64_opt(attrs, "width_px")? + .and_then(i64_to_u32) + .unwrap_or(640), + height_px: map_i64_opt(attrs, "height_px")?.and_then(i64_to_u32), + }), + Some("fit_width") | None => Ok(InputImageSizing::FitWidth), + Some(_) => Ok(InputImageSizing::FitWidth), + } +} + +fn alignment(attrs: Option<&LoroMap>) -> io::Result { + let Some(attrs) = attrs else { + return Ok(InputBlockAlignment::Left); + }; + Ok(match map_string_opt(attrs, "alignment")?.as_deref() { + Some("center") => InputBlockAlignment::Center, + Some("right") => InputBlockAlignment::Right, + Some("left") | None => InputBlockAlignment::Left, + Some(_) => InputBlockAlignment::Left, + }) +} + +fn equation_syntax(attrs: Option<&LoroMap>) -> io::Result { + let Some(attrs) = attrs else { + return Ok(InputEquationSyntax::Latex); + }; + Ok(match map_string_opt(attrs, "syntax")?.as_deref() { + Some("latex") | None => InputEquationSyntax::Latex, + Some(_) => InputEquationSyntax::Latex, + }) +} + +fn equation_display(attrs: Option<&LoroMap>) -> io::Result { + let Some(attrs) = attrs else { + return Ok(InputEquationDisplay::Display); + }; + Ok(match map_string_opt(attrs, "display")?.as_deref() { + Some("inline_like_paragraph") => InputEquationDisplay::InlineLikeParagraph, + Some("display") | None => InputEquationDisplay::Display, + Some(_) => InputEquationDisplay::Display, + }) +} + +fn table_column_width(column: &LoroMap) -> io::Result { + Ok(match map_string_opt(column, "width_kind")?.as_deref() { + Some("fixed_px") => InputTableColumnWidth::FixedPx( + map_i64_opt(column, "width_px")? + .and_then(i64_to_u32) + .unwrap_or(120), + ), + Some("fraction") => InputTableColumnWidth::Fraction( + map_i64_opt(column, "fraction")? + .and_then(i64_to_u32) + .unwrap_or(1), + ), + Some("auto") | None => InputTableColumnWidth::Auto, + Some(_) => InputTableColumnWidth::Auto, + }) +} + +fn parse_u128(value: &str) -> Option { + value.parse::().ok() +} + +fn i64_to_u32(value: i64) -> Option { + u32::try_from(value).ok() +} + +fn i64_to_u16(value: i64) -> Option { + u16::try_from(value).ok() +} + +fn i64_to_u8(value: i64) -> Option { + u8::try_from(value).ok() +} + +fn section_id_field(map: &LoroMap, key: &str) -> io::Result> { + Ok(map_string_opt(map, key)?.and_then(|value| parse_u128(&value))) +} + +/// §11: read a section's page-structure attributes back from the canonical Loro +/// document, substituting documented defaults for any missing keys. Returns +/// `None` only when the named section does not exist. +/// +/// `DocumentProjection` now carries these attrs on `DocumentSection::page`, +/// populated from canonical Loro during projection (see +/// [`Projector::sections_for_projection`]). This helper remains the direct, +/// single-section read-back path for callers that only need one section's attrs +/// without projecting the whole document. The canonical values always live in +/// Loro and round-trip losslessly. +#[must_use] +pub fn section_page_attrs(doc: &LoroDoc, section_id: &str) -> Option { + let root = doc.get_map(ROOT); + let sections = child_map(&root, SECTIONS_BY_ID).ok().flatten()?; + let section = child_map(§ions, section_id).ok().flatten()?; + let attrs = child_map(§ion, "attrs").ok().flatten()?; + Some(crate::loro_schema::read_section_page_attrs(&attrs)) +} + +/// §11: map canonical Loro page-structure attrs +/// (`crate::loro_schema::SectionPageAttrs`) onto the gpui-flowtext projection +/// mirror (`gpui_flowtext::SectionPageAttrs`). gpui-flowtext cannot depend on +/// `flowstate-document`, so the two types are defined field-for-field +/// identically and this is a direct copy. Fully-qualified paths disambiguate the +/// clashing type names. Takes the canonical attrs by value so the owned +/// header/footer flow id strings move rather than clone. +fn project_section_page_attrs(attrs: crate::loro_schema::SectionPageAttrs) -> gpui_flowtext::SectionPageAttrs { + gpui_flowtext::SectionPageAttrs { + page_size: gpui_flowtext::SectionPageSize { + width_twips: attrs.page_size.width_twips, + height_twips: attrs.page_size.height_twips, + }, + margins: gpui_flowtext::SectionMargins { + top_twips: attrs.margins.top_twips, + right_twips: attrs.margins.right_twips, + bottom_twips: attrs.margins.bottom_twips, + left_twips: attrs.margins.left_twips, + }, + columns: attrs.columns, + orientation: match attrs.orientation { + crate::loro_schema::SectionOrientation::Portrait => gpui_flowtext::SectionOrientation::Portrait, + crate::loro_schema::SectionOrientation::Landscape => gpui_flowtext::SectionOrientation::Landscape, + }, + page_numbering: gpui_flowtext::SectionPageNumbering { + format: match attrs.page_numbering.format { + crate::loro_schema::PageNumberFormat::None => gpui_flowtext::PageNumberFormat::None, + crate::loro_schema::PageNumberFormat::Decimal => gpui_flowtext::PageNumberFormat::Decimal, + crate::loro_schema::PageNumberFormat::LowerRoman => gpui_flowtext::PageNumberFormat::LowerRoman, + crate::loro_schema::PageNumberFormat::UpperRoman => gpui_flowtext::PageNumberFormat::UpperRoman, + crate::loro_schema::PageNumberFormat::LowerAlpha => gpui_flowtext::PageNumberFormat::LowerAlpha, + crate::loro_schema::PageNumberFormat::UpperAlpha => gpui_flowtext::PageNumberFormat::UpperAlpha, + }, + start: attrs.page_numbering.start, + }, + header_flow_id: attrs.header_flow_id, + footer_flow_id: attrs.footer_flow_id, + } +} + +fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{document_to_loro, flowstate_document_theme, loro_schema::body_text}; + use gpui_flowtext::{ + InputBlock, InputBlockAlignment, InputImageBlock, InputImageSizing, InputParagraph, InputRun, RunStyles, document_from_input_blocks, + }; + + #[test] + fn projection_preserves_loro_paragraph_and_block_ids() -> io::Result<()> { + let source = document_from_input_blocks( + DocumentTheme::clone(&flowstate_document_theme()), + vec![ + InputBlock::Paragraph(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "before".to_string(), + styles: RunStyles::default(), + }], + }), + InputBlock::Image(InputImageBlock { + asset_id: AssetId(42), + alt_text: "alt".into(), + caption: None, + sizing: InputImageSizing::FitWidth, + alignment: InputBlockAlignment::Left, + }), + ], + ); + let doc = document_to_loro(&source, "Projection ids")?; + let body = body_text(&doc); + let root = doc.get_map(ROOT); + let blocks = child_map(&root, BLOCKS_BY_ID)?.expect("blocks map"); + let first_paragraph_id = paragraph_loro_id_at_boundary(&doc, &body, 0).expect("first paragraph id"); + let first_block_id = paragraph_block_loro_id_at_boundary(&doc, &body, 0).expect("first paragraph block id"); + let image_id = map_keys(&blocks) + .into_iter() + .find(|key| { + child_map(&blocks, key) + .ok() + .flatten() + .and_then(|block| map_string_opt(&block, "kind").ok().flatten()) + .as_deref() + == Some("image") + }) + .expect("image block id"); + + let projected = document_from_loro(&doc)?; + + assert_eq!(projected.ids.paragraph_ids[0], ParagraphId(loro_id_u128(&first_paragraph_id))); + assert_eq!(projected.ids.block_ids[0], BlockId(loro_id_u128(&first_block_id))); + assert_eq!(projected.ids.block_ids[1], BlockId(loro_id_u128(&image_id))); + Ok(()) + } + + #[test] + fn object_boundary_does_not_create_a_phantom_paragraph() -> io::Result<()> { + let paragraph = |text: &str| { + InputBlock::Paragraph(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: vec![InputRun { + text: text.to_string(), + styles: RunStyles::default(), + }], + }) + }; + let source = document_from_input_blocks( + DocumentTheme::clone(&flowstate_document_theme()), + vec![ + paragraph("before"), + InputBlock::Image(InputImageBlock { + asset_id: AssetId(7), + alt_text: "figure".into(), + caption: None, + sizing: InputImageSizing::Intrinsic, + alignment: InputBlockAlignment::Center, + }), + paragraph("after"), + ], + ); + + let projected = document_from_loro(&document_to_loro(&source, "Object boundary")?)?; + + assert_eq!(projected.paragraphs.len(), 2); + assert_eq!(gpui_flowtext::paragraph_text(&projected, 0), "before"); + assert_eq!(gpui_flowtext::paragraph_text(&projected, 1), "after"); + assert!(matches!( + projected.blocks.as_slice(), + [ + gpui_flowtext::Block::Paragraph(_), + gpui_flowtext::Block::Image(_), + gpui_flowtext::Block::Paragraph(_) + ] + )); + Ok(()) + } + + #[test] + fn section_page_attrs_read_back_from_loro() { + let doc = crate::loro_schema::new_loro_document("Sections").expect("new Loro document"); + let expected = crate::loro_schema::SectionPageAttrs { + columns: 3, + orientation: crate::loro_schema::SectionOrientation::Landscape, + ..crate::loro_schema::SectionPageAttrs::default() + }; + crate::loro_schema::set_section_page_attrs(&doc, "section.alpha", &expected).expect("set section page attrs"); + + assert_eq!(section_page_attrs(&doc, "section.alpha"), Some(expected)); + assert_eq!(section_page_attrs(&doc, "section.missing"), None); + } +} diff --git a/crates/flowstate-document/src/loro_schema.rs b/crates/flowstate-document/src/loro_schema.rs new file mode 100644 index 00000000..1056352f --- /dev/null +++ b/crates/flowstate-document/src/loro_schema.rs @@ -0,0 +1,665 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use loro::{ + ContainerTrait as _, ExpandType, LoroDoc, LoroMap, LoroResult, LoroText, LoroValue, StyleConfig, StyleConfigMap, ValueOrContainer, + cursor::Side, +}; +use uuid::Uuid; + +use crate::LORO_SCHEMA_VERSION; + +pub const ROOT: &str = "flowstate.root"; +pub const META: &str = "meta"; +pub const FLOWS_BY_ID: &str = "flows_by_id"; +pub const BLOCKS_BY_ID: &str = "blocks_by_id"; +pub const PARAGRAPHS_BY_ID: &str = "paragraphs_by_id"; +pub const SECTIONS_BY_ID: &str = "sections_by_id"; +pub const ASSETS_BY_ID: &str = "assets_by_id"; +pub const REVISIONS: &str = "revisions"; +pub const USERS_BY_ID: &str = "users_by_id"; +pub const REPLICAS_BY_ID: &str = "replicas_by_id"; + +pub const FLOW_TEXT_KEY: &str = "text"; +pub const FLOW_ATTRS_KEY: &str = "attrs"; +pub const FLOW_KIND_KEY: &str = "kind"; +pub const FLOW_ID_KEY: &str = "id"; + +pub const BODY_FLOW_ID: &str = "body"; +pub const ROOT_BODY_FLOW_ID: &str = "body"; +pub const ROOT_FIRST_PARAGRAPH_ID: &str = "paragraph.initial"; +pub const MAIN_BODY_BLOCK_ID: &str = "block.body.initial"; + +pub const MARK_PARAGRAPH_STYLE: &str = "paragraph_style"; +pub const MARK_RUN_SEMANTIC_STYLE: &str = "run_semantic_style_id"; +pub const MARK_HIGHLIGHT_STYLE: &str = "highlight_style_id"; +pub const MARK_DIRECT_UNDERLINE: &str = "direct_underline"; +pub const MARK_STRIKETHROUGH: &str = "strikethrough"; + +pub const OBJECT_REPLACEMENT: char = '\u{FFFC}'; +pub const SENTINEL_NEWLINE: &str = "\n"; + +/// §11 page-structure attribute keys stored on a `SectionMap`'s `attrs` map. +/// +/// Encoding (documented and locked): +/// * page size: two `i64` keys in twips (1/1440 inch) — `page_width_twips`, +/// `page_height_twips`. US Letter defaults to `12240 x 15840`. +/// * margins: four `i64` keys in twips — `margin_{top,right,bottom,left}_twips`. +/// The default 1-inch margin is `1440` twips. +/// * columns: one `i64` key — `columns` (default `1`). +/// * orientation: one string key — `orientation` (`"portrait"` | `"landscape"`). +/// * page numbering: a small struct stored as `page_numbering_format` (string) +/// plus `page_numbering_start` (`i64`, default `1`). +/// * header/footer: optional `header_flow_id` / `footer_flow_id` string keys whose +/// values name independent header/footer text flows (kinds `"header"`/`"footer"`). +/// +/// Twips are used because they are the DOCX-native, integer-exact unit, keeping the +/// first-class DOCX import/export pipeline lossless. +pub const SECTION_ATTR_PAGE_WIDTH: &str = "page_width_twips"; +pub const SECTION_ATTR_PAGE_HEIGHT: &str = "page_height_twips"; +pub const SECTION_ATTR_MARGIN_TOP: &str = "margin_top_twips"; +pub const SECTION_ATTR_MARGIN_RIGHT: &str = "margin_right_twips"; +pub const SECTION_ATTR_MARGIN_BOTTOM: &str = "margin_bottom_twips"; +pub const SECTION_ATTR_MARGIN_LEFT: &str = "margin_left_twips"; +pub const SECTION_ATTR_COLUMNS: &str = "columns"; +pub const SECTION_ATTR_ORIENTATION: &str = "orientation"; +pub const SECTION_ATTR_PAGE_NUMBERING_FORMAT: &str = "page_numbering_format"; +pub const SECTION_ATTR_PAGE_NUMBERING_START: &str = "page_numbering_start"; +pub const SECTION_ATTR_HEADER_FLOW_ID: &str = "header_flow_id"; +pub const SECTION_ATTR_FOOTER_FLOW_ID: &str = "footer_flow_id"; + +/// US Letter width in twips (8.5in x 1440). +const DEFAULT_PAGE_WIDTH_TWIPS: i64 = 12_240; +/// US Letter height in twips (11in x 1440). +const DEFAULT_PAGE_HEIGHT_TWIPS: i64 = 15_840; +/// One-inch margin in twips. +const DEFAULT_MARGIN_TWIPS: i64 = 1_440; + +/// Section page orientation (§11). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SectionOrientation { + Portrait, + Landscape, +} + +impl SectionOrientation { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Portrait => "portrait", + Self::Landscape => "landscape", + } + } + + #[must_use] + pub fn from_attr(value: Option<&str>) -> Self { + match value { + Some("landscape") => Self::Landscape, + _ => Self::Portrait, + } + } +} + +/// Page-number rendering format for a section (§11). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PageNumberFormat { + None, + Decimal, + LowerRoman, + UpperRoman, + LowerAlpha, + UpperAlpha, +} + +impl PageNumberFormat { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Decimal => "decimal", + Self::LowerRoman => "lower_roman", + Self::UpperRoman => "upper_roman", + Self::LowerAlpha => "lower_alpha", + Self::UpperAlpha => "upper_alpha", + } + } + + #[must_use] + pub fn from_attr(value: Option<&str>) -> Self { + match value { + Some("decimal") => Self::Decimal, + Some("lower_roman") => Self::LowerRoman, + Some("upper_roman") => Self::UpperRoman, + Some("lower_alpha") => Self::LowerAlpha, + Some("upper_alpha") => Self::UpperAlpha, + _ => Self::None, + } + } +} + +/// Page size for a section, in twips (§11). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SectionPageSize { + pub width_twips: i64, + pub height_twips: i64, +} + +impl Default for SectionPageSize { + fn default() -> Self { + Self { + width_twips: DEFAULT_PAGE_WIDTH_TWIPS, + height_twips: DEFAULT_PAGE_HEIGHT_TWIPS, + } + } +} + +/// Section margins, in twips (§11). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SectionMargins { + pub top_twips: i64, + pub right_twips: i64, + pub bottom_twips: i64, + pub left_twips: i64, +} + +impl Default for SectionMargins { + fn default() -> Self { + Self { + top_twips: DEFAULT_MARGIN_TWIPS, + right_twips: DEFAULT_MARGIN_TWIPS, + bottom_twips: DEFAULT_MARGIN_TWIPS, + left_twips: DEFAULT_MARGIN_TWIPS, + } + } +} + +/// Section page-numbering descriptor (§11). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SectionPageNumbering { + pub format: PageNumberFormat, + pub start: i64, +} + +impl Default for SectionPageNumbering { + fn default() -> Self { + Self { + format: PageNumberFormat::None, + start: 1, + } + } +} + +/// §11 page-structure attributes carried by a `SectionMap`. +/// +/// These are canonical in Loro regardless of whether a projection type can hold +/// them. They round-trip losslessly through [`write_section_page_attrs`] / +/// [`read_section_page_attrs`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SectionPageAttrs { + pub page_size: SectionPageSize, + pub margins: SectionMargins, + pub columns: i64, + pub orientation: SectionOrientation, + pub page_numbering: SectionPageNumbering, + pub header_flow_id: Option, + pub footer_flow_id: Option, +} + +impl Default for SectionPageAttrs { + fn default() -> Self { + Self { + page_size: SectionPageSize::default(), + margins: SectionMargins::default(), + columns: 1, + orientation: SectionOrientation::Portrait, + page_numbering: SectionPageNumbering::default(), + header_flow_id: None, + footer_flow_id: None, + } + } +} + +pub fn new_loro_document(title: &str) -> LoroResult { + let doc = LoroDoc::new(); + init_loro_document(&doc, title)?; + Ok(doc) +} + +pub(crate) fn new_loro_import_document(title: &str) -> LoroResult { + let doc = LoroDoc::new(); + init_loro_document_structure(&doc, title, false)?; + Ok(doc) +} + +pub fn init_loro_document(doc: &LoroDoc, title: &str) -> LoroResult<()> { + init_loro_document_structure(doc, title, true)?; + doc.commit(); + Ok(()) +} + +fn init_loro_document_structure(doc: &LoroDoc, title: &str, include_initial_paragraph: bool) -> LoroResult<()> { + configure_text_styles(doc); + + let root = doc.get_map(ROOT); + let meta = root.ensure_mergeable_map(META)?; + let flows = root.ensure_mergeable_map(FLOWS_BY_ID)?; + let blocks = root.ensure_mergeable_map(BLOCKS_BY_ID)?; + let paragraphs = root.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let sections = root.ensure_mergeable_map(SECTIONS_BY_ID)?; + let assets = root.ensure_mergeable_map(ASSETS_BY_ID)?; + let revisions = root.ensure_mergeable_list(REVISIONS)?; + let users = root.ensure_mergeable_map(USERS_BY_ID)?; + let replicas = root.ensure_mergeable_map(REPLICAS_BY_ID)?; + + init_meta(&meta, title)?; + meta.insert("root_container_id", root.id().to_string())?; + meta.insert("flows_container_id", flows.id().to_string())?; + meta.insert("blocks_container_id", blocks.id().to_string())?; + meta.insert("paragraphs_container_id", paragraphs.id().to_string())?; + meta.insert("sections_container_id", sections.id().to_string())?; + meta.insert("assets_container_id", assets.id().to_string())?; + meta.insert("revisions_container_id", revisions.id().to_string())?; + meta.insert("users_container_id", users.id().to_string())?; + meta.insert("replicas_container_id", replicas.id().to_string())?; + let body_flow = ensure_flow(&flows, ROOT_BODY_FLOW_ID, "body")?; + let body_text = body_flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + body_flow.ensure_mergeable_map(FLOW_ATTRS_KEY)?; + if include_initial_paragraph { + ensure_sentinel(&body_text)?; + ensure_initial_paragraph(¶graphs, &blocks, &body_text)?; + } + Ok(()) +} + +pub fn configure_text_styles(doc: &LoroDoc) { + let mut styles = StyleConfigMap::new(); + let no_expand = StyleConfig::new().expand(ExpandType::None); + styles.insert(MARK_PARAGRAPH_STYLE.into(), no_expand); + styles.insert(MARK_RUN_SEMANTIC_STYLE.into(), no_expand); + styles.insert(MARK_HIGHLIGHT_STYLE.into(), no_expand); + styles.insert(MARK_DIRECT_UNDERLINE.into(), no_expand); + styles.insert(MARK_STRIKETHROUGH.into(), no_expand); + doc.config_text_style(styles); +} + +pub fn root_map(doc: &LoroDoc) -> LoroMap { + doc.get_map(ROOT) +} + +pub fn body_text(doc: &LoroDoc) -> LoroText { + let root = root_map(doc); + let flows = root + .ensure_mergeable_map(FLOWS_BY_ID) + .expect("root flows map should be initialized"); + let body = flows + .ensure_mergeable_map(ROOT_BODY_FLOW_ID) + .expect("body flow should be initialized"); + body + .ensure_mergeable_text(FLOW_TEXT_KEY) + .expect("body text should be initialized") +} + +pub fn record_revision( + doc: &LoroDoc, + revision_id: u128, + frontier: Vec, + title: &str, + summary: &str, + author_user_id: Option, +) -> LoroResult<()> { + let root = root_map(doc); + let revisions = root.ensure_mergeable_list(REVISIONS)?; + let revision = revisions.insert_container(revisions.len(), LoroMap::new())?; + revision.insert("id", revision_id.to_string())?; + revision.insert("timestamp", unix_time_secs())?; + revision.insert("replica_id", doc.peer_id().to_string())?; + revision.insert("frontier", frontier)?; + revision.insert("title", title)?; + revision.insert("summary", summary)?; + if let Some(author_user_id) = author_user_id { + revision.insert("author_user_id", author_user_id.to_string())?; + } + doc.commit(); + Ok(()) +} + +pub fn set_document_id(doc: &LoroDoc, document_id: Uuid) -> LoroResult<()> { + let meta = root_map(doc).ensure_mergeable_map(META)?; + meta.insert("document_id", document_id.to_string())?; + touch_document_metadata(doc)?; + Ok(()) +} + +pub fn document_id(doc: &LoroDoc) -> Option { + let ValueOrContainer::Container(container) = root_map(doc).get(META)? else { + return None; + }; + let meta = container.into_map().ok()?; + let ValueOrContainer::Value(LoroValue::String(value)) = meta.get("document_id")? else { + return None; + }; + Uuid::parse_str(&value).ok() +} + +pub fn document_schema_version(doc: &LoroDoc) -> Option { + let ValueOrContainer::Container(container) = root_map(doc).get(META)? else { + return None; + }; + let meta = container.into_map().ok()?; + let ValueOrContainer::Value(LoroValue::I64(version)) = meta.get("loro_schema_version")? else { + return None; + }; + u32::try_from(version).ok() +} + +pub fn fork_document_lineage(doc: &LoroDoc) -> LoroResult { + let root = root_map(doc); + let meta = root.ensure_mergeable_map(META)?; + if let Some(parent_id) = document_id(doc) { + meta.insert("parent_document_id", parent_id.to_string())?; + } + let document_id = Uuid::new_v4(); + meta.insert("document_id", document_id.to_string())?; + meta.insert("forked_at", unix_time_secs())?; + meta.insert("modified_at", unix_time_secs())?; + meta.insert("last_written_by_app_version", env!("CARGO_PKG_VERSION"))?; + doc.commit(); + Ok(document_id) +} + +pub fn touch_document_metadata(doc: &LoroDoc) -> LoroResult<()> { + let meta = root_map(doc).ensure_mergeable_map(META)?; + meta.insert("modified_at", unix_time_secs())?; + meta.insert("last_written_by_app_version", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} + +pub fn register_replica(doc: &LoroDoc, user_id: Option) -> LoroResult { + let root = root_map(doc); + let replicas = root.ensure_mergeable_map(REPLICAS_BY_ID)?; + let replica_id = doc.peer_id().to_string(); + let replica = replicas.ensure_mergeable_map(&replica_id)?; + replica.insert("id", replica_id.as_str())?; + replica.insert("container_id", replica.id().to_string())?; + replica.insert("app_version", env!("CARGO_PKG_VERSION"))?; + if replica.get("created_at").is_none() { + replica.insert("created_at", unix_time_secs())?; + } + replica.insert("last_seen_at", unix_time_secs())?; + if let Some(user_id) = user_id { + replica.insert("user_id", user_id.to_string())?; + } + doc.commit(); + Ok(true) +} + +/// Register (or refresh) a durable user identity in `users_by_id` (§15/§31). +/// +/// Mirrors [`register_replica`]: it writes a `UserMap` record (`id`, +/// `container_id`, optional `display_name`, `created_at`, `last_seen_at`) and +/// links the *current* editing replica to this user so authorship/blame can be +/// resolved from replica to user. Returns `true` once the record is present. +/// +/// The collaboration layer calls this with a real identity; the schema only +/// guarantees the record shape and the replica→user link. +pub fn register_user(doc: &LoroDoc, user_id: u128, display_name: Option<&str>) -> LoroResult { + let root = root_map(doc); + let users = root.ensure_mergeable_map(USERS_BY_ID)?; + let user_key = user_id.to_string(); + let user = users.ensure_mergeable_map(&user_key)?; + user.insert("id", user_key.as_str())?; + user.insert("container_id", user.id().to_string())?; + if let Some(display_name) = display_name { + user.insert("display_name", display_name)?; + } + if user.get("created_at").is_none() { + user.insert("created_at", unix_time_secs())?; + } + user.insert("last_seen_at", unix_time_secs())?; + + // Link the current editing replica to this durable user identity (§15). + let replicas = root.ensure_mergeable_map(REPLICAS_BY_ID)?; + let replica_id = doc.peer_id().to_string(); + let replica = replicas.ensure_mergeable_map(&replica_id)?; + replica.insert("id", replica_id.as_str())?; + replica.insert("user_id", user_key.as_str())?; + if replica.get("created_at").is_none() { + replica.insert("created_at", unix_time_secs())?; + } + replica.insert("last_seen_at", unix_time_secs())?; + doc.commit(); + Ok(true) +} + +/// Ensure a `SectionMap` exists in `sections_by_id` with its identity and an +/// `attrs` child map (§11). Returns the section map. +pub fn ensure_section(doc: &LoroDoc, section_id: &str) -> LoroResult { + let root = root_map(doc); + let sections = root.ensure_mergeable_map(SECTIONS_BY_ID)?; + let section = sections.ensure_mergeable_map(section_id)?; + section.insert("id", section_id)?; + section.insert("container_id", section.id().to_string())?; + let attrs = section.ensure_mergeable_map("attrs")?; + section.insert("attrs_container_id", attrs.id().to_string())?; + Ok(section) +} + +/// Create/update a section's §11 page-structure attributes and, when referenced, +/// its independent header/footer text flows. Commits the change. +pub fn set_section_page_attrs(doc: &LoroDoc, section_id: &str, attrs: &SectionPageAttrs) -> LoroResult<()> { + let section = ensure_section(doc, section_id)?; + let attrs_map = section.ensure_mergeable_map("attrs")?; + let flows = root_map(doc).ensure_mergeable_map(FLOWS_BY_ID)?; + write_section_page_attrs(&attrs_map, &flows, attrs)?; + doc.commit(); + Ok(()) +} + +/// Write §11 page-structure attributes onto a section's `attrs` map using the +/// documented twip/string encoding. When `header_flow_id`/`footer_flow_id` are +/// set, the corresponding header/footer flows are created in `flows`. +pub(crate) fn write_section_page_attrs(attrs_map: &LoroMap, flows: &LoroMap, attrs: &SectionPageAttrs) -> LoroResult<()> { + attrs_map.insert(SECTION_ATTR_PAGE_WIDTH, attrs.page_size.width_twips)?; + attrs_map.insert(SECTION_ATTR_PAGE_HEIGHT, attrs.page_size.height_twips)?; + attrs_map.insert(SECTION_ATTR_MARGIN_TOP, attrs.margins.top_twips)?; + attrs_map.insert(SECTION_ATTR_MARGIN_RIGHT, attrs.margins.right_twips)?; + attrs_map.insert(SECTION_ATTR_MARGIN_BOTTOM, attrs.margins.bottom_twips)?; + attrs_map.insert(SECTION_ATTR_MARGIN_LEFT, attrs.margins.left_twips)?; + attrs_map.insert(SECTION_ATTR_COLUMNS, attrs.columns)?; + attrs_map.insert(SECTION_ATTR_ORIENTATION, attrs.orientation.as_str())?; + attrs_map.insert(SECTION_ATTR_PAGE_NUMBERING_FORMAT, attrs.page_numbering.format.as_str())?; + attrs_map.insert(SECTION_ATTR_PAGE_NUMBERING_START, attrs.page_numbering.start)?; + if let Some(header_flow_id) = &attrs.header_flow_id { + attrs_map.insert(SECTION_ATTR_HEADER_FLOW_ID, header_flow_id.as_str())?; + ensure_flow(flows, header_flow_id, "header")?; + } + if let Some(footer_flow_id) = &attrs.footer_flow_id { + attrs_map.insert(SECTION_ATTR_FOOTER_FLOW_ID, footer_flow_id.as_str())?; + ensure_flow(flows, footer_flow_id, "footer")?; + } + Ok(()) +} + +/// Read §11 page-structure attributes back from a section's `attrs` map, +/// substituting the documented defaults (US Letter, 1-inch margins, 1 column, +/// portrait, no numbering, no header/footer) for any missing keys. +#[must_use] +pub fn read_section_page_attrs(attrs_map: &LoroMap) -> SectionPageAttrs { + let defaults = SectionPageAttrs::default(); + SectionPageAttrs { + page_size: SectionPageSize { + width_twips: map_i64_value(attrs_map, SECTION_ATTR_PAGE_WIDTH).unwrap_or(defaults.page_size.width_twips), + height_twips: map_i64_value(attrs_map, SECTION_ATTR_PAGE_HEIGHT).unwrap_or(defaults.page_size.height_twips), + }, + margins: SectionMargins { + top_twips: map_i64_value(attrs_map, SECTION_ATTR_MARGIN_TOP).unwrap_or(defaults.margins.top_twips), + right_twips: map_i64_value(attrs_map, SECTION_ATTR_MARGIN_RIGHT).unwrap_or(defaults.margins.right_twips), + bottom_twips: map_i64_value(attrs_map, SECTION_ATTR_MARGIN_BOTTOM).unwrap_or(defaults.margins.bottom_twips), + left_twips: map_i64_value(attrs_map, SECTION_ATTR_MARGIN_LEFT).unwrap_or(defaults.margins.left_twips), + }, + columns: map_i64_value(attrs_map, SECTION_ATTR_COLUMNS).unwrap_or(defaults.columns), + orientation: SectionOrientation::from_attr(map_string_value(attrs_map, SECTION_ATTR_ORIENTATION).as_deref()), + page_numbering: SectionPageNumbering { + format: PageNumberFormat::from_attr(map_string_value(attrs_map, SECTION_ATTR_PAGE_NUMBERING_FORMAT).as_deref()), + start: map_i64_value(attrs_map, SECTION_ATTR_PAGE_NUMBERING_START).unwrap_or(defaults.page_numbering.start), + }, + header_flow_id: map_string_value(attrs_map, SECTION_ATTR_HEADER_FLOW_ID), + footer_flow_id: map_string_value(attrs_map, SECTION_ATTR_FOOTER_FLOW_ID), + } +} + +fn map_i64_value(map: &LoroMap, key: &str) -> Option { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::I64(value)) => Some(value), + _ => None, + } +} + +fn map_string_value(map: &LoroMap, key: &str) -> Option { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + } +} + +fn init_meta(meta: &LoroMap, title: &str) -> LoroResult<()> { + let now = unix_time_secs(); + if meta.get("document_id").is_none() { + meta.insert("document_id", Uuid::new_v4().to_string())?; + } + meta.insert("loro_schema_version", i64::from(LORO_SCHEMA_VERSION))?; + meta.insert("schema_features", "flow-v1")?; + meta.insert("title", title)?; + meta.insert("created_by_app_version", env!("CARGO_PKG_VERSION"))?; + meta.insert("last_written_by_app_version", env!("CARGO_PKG_VERSION"))?; + if meta.get("created_at").is_none() { + meta.insert("created_at", now)?; + } + meta.insert("modified_at", now)?; + Ok(()) +} + +fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult { + let flow = flows.ensure_mergeable_map(flow_id)?; + flow.insert(FLOW_ID_KEY, flow_id)?; + flow.insert(FLOW_KIND_KEY, kind)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let attrs = flow.ensure_mergeable_map(FLOW_ATTRS_KEY)?; + flow.insert("container_id", flow.id().to_string())?; + flow.insert("text_container_id", text.id().to_string())?; + flow.insert("attrs_container_id", attrs.id().to_string())?; + Ok(flow) +} + +fn ensure_sentinel(text: &LoroText) -> LoroResult<()> { + if text.len_unicode() == 0 || !text.to_string().starts_with(SENTINEL_NEWLINE) { + text.insert(0, SENTINEL_NEWLINE)?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, 0_i64)?; + } + Ok(()) +} + +fn ensure_initial_paragraph(paragraphs: &LoroMap, blocks: &LoroMap, body: &LoroText) -> LoroResult<()> { + let paragraph = paragraphs.ensure_mergeable_map(ROOT_FIRST_PARAGRAPH_ID)?; + paragraph.insert("id", ROOT_FIRST_PARAGRAPH_ID)?; + paragraph.insert("container_id", paragraph.id().to_string())?; + paragraph.insert("flow_id", ROOT_BODY_FLOW_ID)?; + if let Some(cursor) = body.get_cursor(0, Side::Left) { + paragraph.insert("start_cursor", cursor.encode())?; + } + if let Some(cursor) = body.get_cursor(0, Side::Left) { + paragraph.insert("boundary_cursor", cursor.encode())?; + } + let paragraph_attrs = paragraph.ensure_mergeable_map("attrs")?; + paragraph.insert("attrs_container_id", paragraph_attrs.id().to_string())?; + + let block = blocks.ensure_mergeable_map(MAIN_BODY_BLOCK_ID)?; + block.insert("id", MAIN_BODY_BLOCK_ID)?; + block.insert("container_id", block.id().to_string())?; + block.insert("kind", "paragraph")?; + block.insert("flow_id", ROOT_BODY_FLOW_ID)?; + if let Some(cursor) = body.get_cursor(0, Side::Left) { + block.insert("anchor_cursor", cursor.encode())?; + } + let block_attrs = block.ensure_mergeable_map("attrs")?; + let nested_refs = block.ensure_mergeable_map("nested_refs")?; + block.insert("attrs_container_id", block_attrs.id().to_string())?; + block.insert("nested_refs_container_id", nested_refs.id().to_string())?; + Ok(()) +} + +fn unix_time_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn section_page_attrs_round_trip_through_loro() -> LoroResult<()> { + let doc = new_loro_document("Sections")?; + let attrs = SectionPageAttrs { + page_size: SectionPageSize { + width_twips: 15_840, + height_twips: 12_240, + }, + margins: SectionMargins { + top_twips: 720, + right_twips: 720, + bottom_twips: 1_440, + left_twips: 1_440, + }, + columns: 2, + orientation: SectionOrientation::Landscape, + page_numbering: SectionPageNumbering { + format: PageNumberFormat::LowerRoman, + start: 3, + }, + header_flow_id: Some("section.s1.header".to_string()), + footer_flow_id: Some("section.s1.footer".to_string()), + }; + set_section_page_attrs(&doc, "section.s1", &attrs)?; + + let root = root_map(&doc); + let sections = root.ensure_mergeable_map(SECTIONS_BY_ID)?; + let section = sections.ensure_mergeable_map("section.s1")?; + let attrs_map = section.ensure_mergeable_map("attrs")?; + assert_eq!(read_section_page_attrs(&attrs_map), attrs); + + // §11: header/footer are independent flows of their respective kinds. + let flows = root.ensure_mergeable_map(FLOWS_BY_ID)?; + let header = flows.ensure_mergeable_map("section.s1.header")?; + assert_eq!(map_string_value(&header, FLOW_KIND_KEY).as_deref(), Some("header")); + let footer = flows.ensure_mergeable_map("section.s1.footer")?; + assert_eq!(map_string_value(&footer, FLOW_KIND_KEY).as_deref(), Some("footer")); + Ok(()) + } + + #[test] + fn section_page_attrs_default_to_letter_when_unset() -> LoroResult<()> { + let doc = new_loro_document("Sections")?; + let section = ensure_section(&doc, "section.bare")?; + let attrs_map = section.ensure_mergeable_map("attrs")?; + assert_eq!(read_section_page_attrs(&attrs_map), SectionPageAttrs::default()); + Ok(()) + } + + #[test] + fn register_user_writes_record_and_links_replica() -> LoroResult<()> { + let doc = new_loro_document("Users")?; + register_user(&doc, 0x42, Some("Ada"))?; + + let root = root_map(&doc); + let users = root.ensure_mergeable_map(USERS_BY_ID)?; + let user = users.ensure_mergeable_map("66")?; + assert_eq!(map_string_value(&user, "display_name").as_deref(), Some("Ada")); + assert_eq!(map_string_value(&user, "id").as_deref(), Some("66")); + + let replicas = root.ensure_mergeable_map(REPLICAS_BY_ID)?; + let replica = replicas.ensure_mergeable_map(&doc.peer_id().to_string())?; + assert_eq!(map_string_value(&replica, "user_id").as_deref(), Some("66")); + Ok(()) + } +} diff --git a/crates/flowstate-document/src/package.rs b/crates/flowstate-document/src/package.rs new file mode 100644 index 00000000..295d6304 --- /dev/null +++ b/crates/flowstate-document/src/package.rs @@ -0,0 +1,2504 @@ +use std::{ + collections::HashSet, + fs::{self, OpenOptions}, + io::{self, Cursor, Read as _, Seek as _, SeekFrom, Write as _}, + path::Path, +}; + +use loro::{Container, ExportMode, Frontiers, LoroDoc, LoroValue, ValueOrContainer, VersionVector}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub const LORO_PACKAGE_FORMAT_VERSION: u32 = 1; +pub const LORO_SCHEMA_VERSION: u32 = 1; +pub const DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD: usize = 256; + +const PACKAGE_MAGIC: &[u8; 16] = b"FLOWDB8-LORO\0\0\0\0"; +const PACKAGE_HEADER_VERSION: u32 = 1; +const JOURNAL_MAGIC: &[u8; 16] = b"FLOWDB8-JOURNAL\0"; +const JOURNAL_HEADER_VERSION: u32 = 1; +const JOURNAL_TXN_MAGIC: &[u8; 8] = b"DB8TXN01"; +const JOURNAL_COMMIT_MAGIC: &[u8; 8] = b"DB8DONE1"; +const JOURNAL_DELTA_MAGIC: &[u8; 8] = b"DB8DELTA"; +const JOURNAL_GENERATION_COMPACTION_THRESHOLD: usize = 16; + +const CHUNK_MANIFEST: u32 = 1; +const CHUNK_LORO_SNAPSHOT: u32 = 2; +const CHUNK_LORO_UPDATE_SEGMENT: u32 = 3; +const CHUNK_ASSET: u32 = 4; +const CHUNK_REVISION_INDEX: u32 = 5; +const CHUNK_PROJECTION_CACHE: u32 = 6; +const CHUNK_SEARCH_UNIT: u32 = 7; +const CHUNK_THUMBNAIL: u32 = 8; +const CHUNK_INTEGRITY_INDEX: u32 = 9; + +const PACKAGE_CHUNK_TABLE_ENTRY_BYTES: usize = 4 + 8 + 8 + 32; +const MAX_PACKAGE_CHUNK_COUNT: usize = 1_048_576; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DocumentPackage { + pub manifest: DocumentPackageManifest, + pub loro_snapshots: Vec, + pub loro_update_segments: Vec, + pub assets: Vec, + pub revisions: Vec, + pub projection_caches: Vec, + pub search_units: Vec, + pub thumbnails: Vec, + /// §19 named integrity index: one entry per durable chunk (snapshots, update + /// segments, assets). Older packages without the index decode to an empty + /// vector and still load; new packages always rebuild a complete index. + #[serde(default)] + pub integrity_index: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DocumentPackageManifest { + pub package_format_version: u32, + pub loro_schema_version: u32, + pub document_id: u128, + pub latest_frontier: Vec, + pub latest_version_vector: Vec, + pub latest_snapshot_id: u128, + pub update_segment_index: Vec, + pub asset_index: Vec, + pub projection_cache_frontier: Option>, + pub search_cache_frontier: Option>, + pub created_at_unix_secs: i64, + pub modified_at_unix_secs: i64, + /// §27 schema migration log. Empty at schema v1 (no migrations exist yet). + /// Future schema bumps append a [`SchemaMigrationRecord`] here, and any Loro + /// document mutation a migration performs is an ordinary Loro change committed + /// with the `"migration"` origin. Older packages without this field decode to + /// an empty vector. + #[serde(default)] + pub schema_migrations: Vec, +} + +/// §19 integrity-index entry recording one durable chunk's identity, kind, +/// BLAKE3 checksum, and byte length so package integrity can be cross-checked +/// against the actual chunk payloads on read. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct IntegrityIndexEntry { + pub chunk_kind: u32, + pub id: u128, + pub checksum: [u8; 32], + pub byte_length: u64, +} + +/// §27 record of a schema migration that was applied to this package. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SchemaMigrationRecord { + pub id: u128, + pub from_version: u32, + pub to_version: u32, + pub applied_at_unix_secs: i64, + pub description: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ChunkRef { + pub id: u128, + pub checksum: [u8; 32], + pub byte_length: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LoroSnapshotChunk { + pub snapshot_id: u128, + pub frontier: Vec, + pub version_vector: Vec, + pub bytes: Vec, + pub created_at_unix_secs: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LoroUpdateSegmentChunk { + pub segment_id: u128, + pub from_frontier: Vec, + pub from_version_vector: Vec, + pub to_frontier: Vec, + pub to_version_vector: Vec, + pub bytes: Vec, + pub checksum: [u8; 32], + pub created_at_unix_secs: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AssetChunk { + pub asset_id: u128, + pub content_hash: [u8; 32], + pub mime_type: String, + pub byte_length: u64, + pub bytes: Vec, + pub metadata: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PackageRevision { + pub revision_id: u128, + pub frontier: Vec, + pub version_vector: Vec, + pub title: String, + pub summary: String, + pub author_user_id: Option, + pub replica_id: Option, + pub created_at_unix_secs: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ProjectionCacheChunk { + pub frontier: Vec, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SearchUnitChunk { + pub frontier: Vec, + pub unit_id: u128, + pub unit_kind: String, + #[serde(default)] + pub flow_id: Option, + #[serde(default)] + pub block_id: Option, + #[serde(default)] + pub table_id: Option, + #[serde(default)] + pub cell_id: Option, + pub heading_path: Vec, + pub heading: String, + pub body: String, + pub insert_text: String, + #[serde(default)] + pub unit_start_cursor: Vec, + #[serde(default)] + pub unit_end_cursor: Vec, + pub paragraph_start_cursor: Vec, + pub paragraph_end_cursor: Vec, +} + +#[derive(Clone, Debug)] +struct Chunk { + kind: u32, + payload: Vec, +} + +#[derive(Clone, Debug)] +struct ChunkEntry { + kind: u32, + offset: u64, + len: u64, + checksum: [u8; 32], +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ThumbnailChunk { + pub thumbnail_id: u128, + pub revision_id: Option, + pub frontier: Vec, + pub mime_type: String, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +enum PackageJournalDelta { + Update { + manifest: DocumentPackageManifest, + segment: LoroUpdateSegmentChunk, + }, + Assets { + manifest: DocumentPackageManifest, + assets: Vec, + }, +} + +impl DocumentPackage { + pub fn from_loro_snapshot(doc: &LoroDoc, title: &str) -> io::Result { + Self::from_loro_snapshot_with_assets(doc, title, Vec::new()) + } + + pub fn from_loro_snapshot_with_assets(doc: &LoroDoc, title: &str, assets: Vec) -> io::Result { + doc.commit(); + let now = unix_time_secs(); + let document_id = crate::loro_schema::document_id(doc) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Loro document has no valid canonical document ID"))? + .as_u128(); + let revision_id = Uuid::new_v4().as_u128(); + let revision_frontiers = doc.state_frontiers(); + let revision_doc = doc.fork_at(&revision_frontiers).map_err(loro_io_error)?; + crate::loro_schema::record_revision(doc, revision_id, encode_frontiers(&revision_frontiers), title, "Initial snapshot", None) + .map_err(loro_io_error)?; + let snapshot_id = Uuid::new_v4().as_u128(); + let frontier = encode_frontiers(&doc.state_frontiers()); + let version_vector = encode_version_vector(&doc.state_vv()); + let snapshot = doc.export(ExportMode::Snapshot).map_err(loro_io_error)?; + let mut package = Self { + manifest: DocumentPackageManifest { + package_format_version: LORO_PACKAGE_FORMAT_VERSION, + loro_schema_version: LORO_SCHEMA_VERSION, + document_id, + latest_frontier: frontier.clone(), + latest_version_vector: version_vector.clone(), + latest_snapshot_id: snapshot_id, + update_segment_index: Vec::new(), + asset_index: Vec::new(), + projection_cache_frontier: None, + search_cache_frontier: None, + created_at_unix_secs: now, + modified_at_unix_secs: now, + schema_migrations: Vec::new(), + }, + loro_snapshots: vec![ + LoroSnapshotChunk { + snapshot_id, + frontier: frontier.clone(), + version_vector: version_vector.clone(), + bytes: snapshot, + created_at_unix_secs: now, + }, + LoroSnapshotChunk { + snapshot_id: Uuid::new_v4().as_u128(), + frontier: encode_frontiers(&revision_frontiers), + version_vector: encode_version_vector(&revision_doc.state_vv()), + bytes: revision_doc + .export(ExportMode::Snapshot) + .map_err(loro_io_error)?, + created_at_unix_secs: now, + }, + ], + loro_update_segments: Vec::new(), + assets, + revisions: vec![PackageRevision { + revision_id, + frontier: encode_frontiers(&revision_frontiers), + version_vector: encode_version_vector(&revision_doc.state_vv()), + title: title.to_string(), + summary: "Initial snapshot".to_string(), + author_user_id: None, + replica_id: None, + created_at_unix_secs: now, + }], + projection_caches: Vec::new(), + search_units: Vec::new(), + thumbnails: Vec::new(), + integrity_index: Vec::new(), + } + .with_manifest_indexes()?; + package.rebuild_projection_cache_from_loro(doc)?; + package.rebuild_search_units_from_loro(doc)?; + Ok(package) + } + + pub fn load_loro_doc(&self) -> io::Result { + self.validate()?; + self.load_loro_doc_unvalidated() + } + + fn load_loro_doc_unvalidated(&self) -> io::Result { + let snapshot = self + .latest_snapshot() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate package has no latest Loro snapshot"))?; + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + doc.import(&snapshot.bytes).map_err(loro_io_error)?; + for segment in &self.loro_update_segments { + doc.import(&segment.bytes).map_err(loro_io_error)?; + } + let document_id = crate::loro_schema::document_id(&doc) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "package Loro state has no valid document ID"))?; + if document_id.as_u128() != self.manifest.document_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "package manifest document ID does not match canonical Loro lineage", + )); + } + if crate::loro_schema::document_schema_version(&doc) != Some(self.manifest.loro_schema_version) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "package manifest schema version does not match canonical Loro metadata", + )); + } + Ok(doc) + } + pub fn replace_assets_from_document(&mut self, document: &crate::DocumentProjection) -> io::Result<()> { + let mut next = self.clone(); + next.assets = crate::loro_import::assets_from_document(document); + next.manifest.modified_at_unix_secs = unix_time_secs(); + next.refresh_manifest_indexes(); + next.validate()?; + *self = next; + Ok(()) + } + + pub fn current_search_units(&self) -> &[SearchUnitChunk] { + if self.manifest.search_cache_frontier.as_deref() == Some(self.manifest.latest_frontier.as_slice()) { + &self.search_units + } else { + &[] + } + } + + pub fn read_cached_search_units(path: impl AsRef) -> io::Result>> { + let bytes = fs::read(path)?; + Self::cached_search_units_from_bytes(&bytes) + } + + pub fn cached_search_units_from_bytes(bytes: &[u8]) -> io::Result>> { + let (manifest, units) = if bytes.starts_with(JOURNAL_MAGIC) { + cached_search_units_from_journal_bytes(bytes)? + } else { + cached_search_units_from_compact_bytes(bytes)? + }; + if manifest.package_format_version != LORO_PACKAGE_FORMAT_VERSION || manifest.loro_schema_version != LORO_SCHEMA_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported Flowstate cached search package version", + )); + } + let Some(search_frontier) = manifest.search_cache_frontier.as_deref() else { + return Ok(None); + }; + validate_frontiers(search_frontier, "manifest search cache frontier")?; + validate_frontiers(&manifest.latest_frontier, "manifest latest frontier")?; + if search_frontier != manifest.latest_frontier.as_slice() { + return Ok(None); + } + if units.iter().any(|unit| unit.frontier != search_frontier) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "search unit frontier does not match package cache frontier", + )); + } + Ok(Some(units)) + } + + pub fn append_update_segment( + &mut self, + from_frontier: &Frontiers, + from_version_vector: &VersionVector, + to_frontier: &Frontiers, + to_version_vector: &VersionVector, + bytes: Vec, + ) -> io::Result { + if bytes.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "cannot append an empty Loro update segment")); + } + let segment_id = Uuid::new_v4().as_u128(); + let now = unix_time_secs(); + let checksum = blake3_hash(&bytes); + let mut next = self.clone(); + next.loro_update_segments.push(LoroUpdateSegmentChunk { + segment_id, + from_frontier: encode_frontiers(from_frontier), + from_version_vector: encode_version_vector(from_version_vector), + to_frontier: encode_frontiers(to_frontier), + to_version_vector: encode_version_vector(to_version_vector), + bytes, + checksum, + created_at_unix_secs: now, + }); + next.manifest.latest_frontier = encode_frontiers(to_frontier); + next.manifest.latest_version_vector = encode_version_vector(to_version_vector); + next.manifest.projection_cache_frontier = None; + next.projection_caches.clear(); + next.manifest.search_cache_frontier = None; + next.search_units.clear(); + next.manifest.modified_at_unix_secs = now; + next.refresh_manifest_indexes(); + next.validate()?; + *self = next; + Ok(segment_id) + } + + pub fn create_named_revision( + &mut self, + doc: &LoroDoc, + title: impl Into, + summary: impl Into, + author_user_id: Option, + replica_id: Option, + ) -> io::Result { + self.create_named_revision_with_id(doc, Uuid::new_v4().as_u128(), title, summary, author_user_id, replica_id) + } + + pub fn create_named_revision_with_id( + &mut self, + doc: &LoroDoc, + revision_id: u128, + title: impl Into, + summary: impl Into, + author_user_id: Option, + replica_id: Option, + ) -> io::Result { + self.create_named_revision_at_with_id(doc, revision_id, &doc.state_frontiers(), title, summary, author_user_id, replica_id) + } + + pub fn create_named_revision_at_with_id( + &mut self, + doc: &LoroDoc, + revision_id: u128, + frontiers: &Frontiers, + title: impl Into, + summary: impl Into, + author_user_id: Option, + replica_id: Option, + ) -> io::Result { + doc.commit(); + let title = title.into(); + let summary = summary.into(); + if self + .revisions + .iter() + .any(|revision| revision.revision_id == revision_id) + { + return Ok(revision_id); + } + let doc_frontier_before_revision_record = doc.state_frontiers(); + let doc_vv_before_revision_record = doc.state_vv(); + let revision_doc = doc.fork_at(frontiers).map_err(loro_io_error)?; + let frontier = encode_frontiers(frontiers); + let version_vector = encode_version_vector(&revision_doc.state_vv()); + if !loro_revision_exists(doc, revision_id) { + crate::loro_schema::record_revision(doc, revision_id, frontier.clone(), &title, &summary, author_user_id).map_err(loro_io_error)?; + let update = doc + .export(ExportMode::updates(&doc_vv_before_revision_record)) + .map_err(loro_io_error)?; + if !update.is_empty() { + self.append_update_segment( + &doc_frontier_before_revision_record, + &doc_vv_before_revision_record, + &doc.state_frontiers(), + &doc.state_vv(), + update, + )?; + } + } + let revision = PackageRevision { + revision_id, + frontier: frontier.clone(), + version_vector, + title, + summary, + author_user_id, + replica_id, + created_at_unix_secs: unix_time_secs(), + }; + let revision_id = revision.revision_id; + if self.snapshot_for_frontier(&frontier).is_none() { + self.loro_snapshots.push(LoroSnapshotChunk { + snapshot_id: Uuid::new_v4().as_u128(), + frontier: frontier.clone(), + version_vector: encode_version_vector(&revision_doc.state_vv()), + bytes: revision_doc + .export(ExportMode::Snapshot) + .map_err(loro_io_error)?, + created_at_unix_secs: unix_time_secs(), + }); + } + self.revisions.push(revision); + self.manifest.modified_at_unix_secs = unix_time_secs(); + // §19: a revision may add a snapshot chunk; keep the integrity index complete. + self.integrity_index = self.build_integrity_index(); + self.validate()?; + Ok(revision_id) + } + + pub fn load_revision_loro_doc(&self, revision_id: u128) -> io::Result { + let revision = self + .revisions + .iter() + .find(|revision| revision.revision_id == revision_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Flowstate package revision is missing"))?; + if let Some(snapshot) = self.snapshot_for_frontier(&revision.frontier) { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + doc.import(&snapshot.bytes).map_err(loro_io_error)?; + return Ok(doc); + } + let doc = self.load_loro_doc()?; + let frontiers = decode_frontiers(&revision.frontier)?; + doc.fork_at(&frontiers).map_err(loro_io_error) + } + + pub fn compact_to_named_snapshot( + &mut self, + doc: &LoroDoc, + title: impl Into, + summary: impl Into, + author_user_id: Option, + replica_id: Option, + ) -> io::Result<(u128, u128)> { + self.compact_to_named_snapshot_with_id(doc, Uuid::new_v4().as_u128(), title, summary, author_user_id, replica_id) + } + + pub fn sync_revisions_from_loro(&mut self, doc: &LoroDoc) -> io::Result { + let root = doc.get_map(crate::loro_schema::ROOT); + let Some(ValueOrContainer::Container(Container::List(revisions))) = root.get(crate::loro_schema::REVISIONS) else { + return Ok(0); + }; + let mut added = 0usize; + for index in 0..revisions.len() { + let Some(ValueOrContainer::Container(Container::Map(revision))) = revisions.get(index) else { + continue; + }; + let Some(revision_id) = package_map_string(&revision, "id").and_then(|id| id.parse::().ok()) else { + continue; + }; + if self + .revisions + .iter() + .any(|existing| existing.revision_id == revision_id) + { + continue; + } + let Some(frontier) = package_map_binary(&revision, "frontier") else { + continue; + }; + let frontiers = decode_frontiers(&frontier)?; + let revision_doc = doc.fork_at(&frontiers).map_err(loro_io_error)?; + let version_vector = encode_version_vector(&revision_doc.state_vv()); + if self.snapshot_for_frontier(&frontier).is_none() { + self.loro_snapshots.push(LoroSnapshotChunk { + snapshot_id: Uuid::new_v4().as_u128(), + frontier: frontier.clone(), + version_vector: version_vector.clone(), + bytes: revision_doc + .export(ExportMode::Snapshot) + .map_err(loro_io_error)?, + created_at_unix_secs: package_map_i64(&revision, "timestamp").unwrap_or_else(unix_time_secs), + }); + } + self.revisions.push(PackageRevision { + revision_id, + frontier, + version_vector, + title: package_map_string(&revision, "title").unwrap_or_else(|| "Revision".to_string()), + summary: package_map_string(&revision, "summary").unwrap_or_default(), + author_user_id: package_map_string(&revision, "author_user_id").and_then(|id| id.parse().ok()), + replica_id: package_map_string(&revision, "replica_id").and_then(|id| id.parse().ok()), + created_at_unix_secs: package_map_i64(&revision, "timestamp").unwrap_or_else(unix_time_secs), + }); + added += 1; + } + if added > 0 { + self + .revisions + .sort_by_key(|revision| revision.created_at_unix_secs); + self.manifest.modified_at_unix_secs = unix_time_secs(); + // §19: syncing revisions may add snapshot chunks; keep the index complete. + self.integrity_index = self.build_integrity_index(); + self.validate()?; + } + Ok(added) + } + + pub fn compact_to_named_snapshot_with_id( + &mut self, + doc: &LoroDoc, + revision_id: u128, + title: impl Into, + summary: impl Into, + author_user_id: Option, + replica_id: Option, + ) -> io::Result<(u128, u128)> { + let snapshot_id = self.compact_to_snapshot(doc)?; + let revision_id = self.create_named_revision_with_id(doc, revision_id, title, summary, author_user_id, replica_id)?; + Ok((revision_id, snapshot_id)) + } + + pub fn compact_to_snapshot(&mut self, doc: &LoroDoc) -> io::Result { + doc.commit(); + let snapshot_id = Uuid::new_v4().as_u128(); + let now = unix_time_secs(); + let frontier = encode_frontiers(&doc.state_frontiers()); + let version_vector = encode_version_vector(&doc.state_vv()); + let bytes = doc.export(ExportMode::Snapshot).map_err(loro_io_error)?; + let mut next = self.clone(); + next.loro_snapshots.push(LoroSnapshotChunk { + snapshot_id, + frontier: frontier.clone(), + version_vector, + bytes, + created_at_unix_secs: now, + }); + next.loro_update_segments.clear(); + next.manifest.latest_snapshot_id = snapshot_id; + next.manifest.latest_frontier = frontier; + next.manifest.latest_version_vector = encode_version_vector(&doc.state_vv()); + next.manifest.modified_at_unix_secs = now; + let retained_revision_frontiers = next + .revisions + .iter() + .map(|revision| revision.frontier.clone()) + .collect::>(); + next.loro_snapshots.retain(|snapshot| { + snapshot.snapshot_id == snapshot_id + || retained_revision_frontiers + .iter() + .any(|frontier| frontier.as_slice() == snapshot.frontier.as_slice()) + }); + next.refresh_manifest_indexes(); + next.rebuild_projection_cache_from_loro(doc)?; + next.rebuild_search_units_from_loro(doc)?; + *self = next; + Ok(snapshot_id) + } + + pub fn compact_update_segments_if_needed(&mut self, doc: &LoroDoc, max_update_segments: usize) -> io::Result> { + if max_update_segments == 0 || self.loro_update_segments.len() <= max_update_segments { + return Ok(None); + } + self.compact_to_snapshot(doc).map(Some) + } + + pub fn rebuild_search_units_from_loro(&mut self, doc: &LoroDoc) -> io::Result<()> { + doc.commit(); + let frontier = encode_frontiers(&doc.state_frontiers()); + self.search_units = crate::package_search::search_units_from_loro(doc, self.manifest.document_id, &frontier)?; + self.manifest.search_cache_frontier = Some(frontier); + self.manifest.modified_at_unix_secs = unix_time_secs(); + self.validate()?; + Ok(()) + } + + pub fn read(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let bytes = fs::read(path)?; + let package = Self::from_bytes(&bytes)?; + if bytes.starts_with(JOURNAL_MAGIC) { + let (_, committed_end) = committed_journal_transactions(&bytes)?; + if committed_end != bytes.len() { + let file = OpenOptions::new().write(true).open(path)?; + file.set_len(committed_end as u64)?; + file.sync_all()?; + } + } + Ok(package) + } + + pub fn write(&self, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + let payload = self.to_bytes()?; + if file_has_journal_header(path)? { + let bytes = fs::read(path)?; + let (transactions, committed_end) = committed_journal_transactions(&bytes)?; + let rewrite = committed_end != bytes.len() + || transactions.len() >= JOURNAL_GENERATION_COMPACTION_THRESHOLD + || bytes.len() > journal_transaction_len(payload.len()).saturating_mul(4); + if rewrite { + write_journal_generation(path, &payload) + } else { + append_journal_transaction(path, &payload) + } + } else { + write_journal_generation(path, &payload) + } + } + + pub fn append_latest_update_to_path(&self, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + let Some(segment) = self.loro_update_segments.last().cloned() else { + return self.write(path); + }; + if !file_has_journal_header(path)? { + return self.write(path); + } + let payload = encode_journal_delta(&PackageJournalDelta::Update { + manifest: self.manifest.clone(), + segment, + })?; + append_journal_transaction(path, &payload) + } + + pub fn append_latest_update_to_prepared_path(&self, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + let Some(segment) = self.loro_update_segments.last().cloned() else { + return self.write(path); + }; + if !file_has_journal_header(path)? { + return self.write(path); + } + let payload = encode_journal_delta(&PackageJournalDelta::Update { + manifest: self.manifest.clone(), + segment, + })?; + append_journal_transaction_to_prepared_file(path, &payload) + } + + pub fn append_assets_to_path(&self, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + if !file_has_journal_header(path)? { + return self.write(path); + } + let payload = encode_journal_delta(&PackageJournalDelta::Assets { + manifest: self.manifest.clone(), + assets: self.assets.clone(), + })?; + append_journal_transaction(path, &payload) + } + + pub fn from_bytes(bytes: &[u8]) -> io::Result { + if bytes.starts_with(JOURNAL_MAGIC) { + return Self::from_journal_bytes(bytes); + } + Self::from_compact_bytes(bytes) + } + + fn from_journal_bytes(bytes: &[u8]) -> io::Result { + let mut package = None; + for payload in committed_journal_payloads(bytes)? { + if payload.starts_with(PACKAGE_MAGIC) { + package = Some(Self::from_compact_bytes(payload)?); + continue; + } + let delta = decode_journal_delta(payload)?; + let current = package + .as_mut() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate package journal delta precedes a full generation"))?; + match delta { + PackageJournalDelta::Update { manifest, segment } => { + if !current + .loro_update_segments + .iter() + .any(|existing| existing.segment_id == segment.segment_id) + { + current.loro_update_segments.push(segment); + } + current.manifest = manifest; + if current.manifest.projection_cache_frontier.is_none() { + current.projection_caches.clear(); + } + if current.manifest.search_cache_frontier.is_none() { + current.search_units.clear(); + } + }, + PackageJournalDelta::Assets { manifest, assets } => { + current.manifest = manifest; + current.assets = assets; + }, + } + } + let mut package = + package.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate package journal has no complete full generation"))?; + // §19: journal deltas append update segments after the base generation, so + // rebuild the integrity index over the reconstructed chunk set before + // validating it for consistency. The manifest's own segment/asset indexes + // arrive with the delta and remain checked by `validate_manifest_indexes`. + package.integrity_index = package.build_integrity_index(); + package.validate()?; + Ok(package) + } + + fn from_compact_bytes(bytes: &[u8]) -> io::Result { + let chunks = read_chunks(bytes)?; + let mut manifest = None; + let mut loro_snapshots = Vec::new(); + let mut loro_update_segments = Vec::new(); + let mut assets = Vec::new(); + let mut revisions = Vec::new(); + let mut projection_caches = Vec::new(); + let mut search_units = Vec::new(); + let mut thumbnails = Vec::new(); + let mut integrity_index = Vec::new(); + + for chunk in chunks { + match chunk.kind { + CHUNK_MANIFEST => manifest = Some(decode_chunk(&chunk.payload, "manifest")?), + CHUNK_LORO_SNAPSHOT => loro_snapshots.push(decode_chunk(&chunk.payload, "Loro snapshot")?), + CHUNK_LORO_UPDATE_SEGMENT => loro_update_segments.push(decode_chunk(&chunk.payload, "Loro update segment")?), + CHUNK_ASSET => assets.push(decode_chunk(&chunk.payload, "asset")?), + CHUNK_REVISION_INDEX => revisions.push(decode_chunk(&chunk.payload, "revision")?), + CHUNK_PROJECTION_CACHE => projection_caches.push(decode_chunk(&chunk.payload, "projection cache")?), + CHUNK_SEARCH_UNIT => search_units.push(decode_chunk(&chunk.payload, "search unit")?), + CHUNK_THUMBNAIL => thumbnails.push(decode_chunk(&chunk.payload, "thumbnail")?), + CHUNK_INTEGRITY_INDEX => integrity_index.push(decode_chunk(&chunk.payload, "integrity index entry")?), + _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "unknown Flowstate package chunk kind")), + } + } + + let package = Self { + manifest: manifest.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate package has no manifest"))?, + loro_snapshots, + loro_update_segments, + assets, + revisions, + projection_caches, + search_units, + thumbnails, + integrity_index, + }; + package.validate()?; + Ok(package) + } + + pub fn to_bytes(&self) -> io::Result> { + self.validate()?; + let mut chunks = Vec::new(); + chunks.push(Chunk { + kind: CHUNK_MANIFEST, + payload: encode_chunk(&self.manifest, "manifest")?, + }); + for snapshot in &self.loro_snapshots { + chunks.push(Chunk { + kind: CHUNK_LORO_SNAPSHOT, + payload: encode_chunk(snapshot, "Loro snapshot")?, + }); + } + for segment in &self.loro_update_segments { + chunks.push(Chunk { + kind: CHUNK_LORO_UPDATE_SEGMENT, + payload: encode_chunk(segment, "Loro update segment")?, + }); + } + for asset in &self.assets { + chunks.push(Chunk { + kind: CHUNK_ASSET, + payload: encode_chunk(asset, "asset")?, + }); + } + for revision in &self.revisions { + chunks.push(Chunk { + kind: CHUNK_REVISION_INDEX, + payload: encode_chunk(revision, "revision")?, + }); + } + for cache in &self.projection_caches { + chunks.push(Chunk { + kind: CHUNK_PROJECTION_CACHE, + payload: encode_chunk(cache, "projection cache")?, + }); + } + for unit in &self.search_units { + chunks.push(Chunk { + kind: CHUNK_SEARCH_UNIT, + payload: encode_chunk(unit, "search unit")?, + }); + } + for thumbnail in &self.thumbnails { + chunks.push(Chunk { + kind: CHUNK_THUMBNAIL, + payload: encode_chunk(thumbnail, "thumbnail")?, + }); + } + for entry in &self.integrity_index { + chunks.push(Chunk { + kind: CHUNK_INTEGRITY_INDEX, + payload: encode_chunk(entry, "integrity index entry")?, + }); + } + write_chunks(&chunks) + } + + pub fn validate(&self) -> io::Result<()> { + if self.manifest.package_format_version != LORO_PACKAGE_FORMAT_VERSION { + return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported Flowstate package format version")); + } + if self.manifest.loro_schema_version != LORO_SCHEMA_VERSION { + return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported Flowstate Loro schema version")); + } + let snapshot = self + .latest_snapshot() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "latest Loro snapshot is missing"))?; + if self.loro_update_segments.is_empty() && snapshot.frontier != self.manifest.latest_frontier { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "latest snapshot frontier does not match manifest", + )); + } + if let Some(last_segment) = self.loro_update_segments.last() + && last_segment.to_frontier != self.manifest.latest_frontier + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "latest update segment frontier does not match manifest", + )); + } + validate_frontiers(&self.manifest.latest_frontier, "manifest latest frontier")?; + validate_version_vector(&self.manifest.latest_version_vector, "manifest latest version vector")?; + if let Some(frontier) = &self.manifest.projection_cache_frontier { + validate_frontiers(frontier, "manifest projection cache frontier")?; + if !self + .projection_caches + .iter() + .any(|cache| cache.frontier == *frontier) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "manifest projection cache frontier has no cache chunk", + )); + } + } + if let Some(frontier) = &self.manifest.search_cache_frontier { + validate_frontiers(frontier, "manifest search cache frontier")?; + } + for snapshot in &self.loro_snapshots { + validate_frontiers(&snapshot.frontier, "snapshot frontier")?; + validate_version_vector(&snapshot.version_vector, "snapshot version vector")?; + if snapshot.bytes.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "empty Loro snapshot bytes")); + } + } + for revision in &self.revisions { + validate_frontiers(&revision.frontier, "revision frontier")?; + validate_version_vector(&revision.version_vector, "revision version vector")?; + } + for cache in &self.projection_caches { + validate_frontiers(&cache.frontier, "projection cache frontier")?; + decode_chunk::(&cache.bytes, "projection cache payload")?; + } + for thumbnail in &self.thumbnails { + validate_frontiers(&thumbnail.frontier, "thumbnail frontier")?; + if thumbnail.bytes.is_empty() || thumbnail.mime_type.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid empty thumbnail chunk")); + } + if let Some(revision_id) = thumbnail.revision_id + && !self + .revisions + .iter() + .any(|revision| revision.revision_id == revision_id) + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "thumbnail references an unknown revision")); + } + } + if self + .loro_update_segments + .iter() + .any(|segment| segment.checksum != blake3_hash(&segment.bytes)) + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Loro update segment checksum mismatch")); + } + for segment in &self.loro_update_segments { + validate_frontiers(&segment.from_frontier, "update segment from frontier")?; + validate_version_vector(&segment.from_version_vector, "update segment from version vector")?; + validate_frontiers(&segment.to_frontier, "update segment to frontier")?; + validate_version_vector(&segment.to_version_vector, "update segment to version vector")?; + } + let mut expected_frontier = snapshot.frontier.as_slice(); + let mut expected_version_vector = snapshot.version_vector.as_slice(); + for segment in &self.loro_update_segments { + if segment.from_frontier != expected_frontier { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Loro update segment frontier chain is broken")); + } + if segment.from_version_vector != expected_version_vector { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Loro update segment version-vector chain is broken", + )); + } + expected_frontier = &segment.to_frontier; + expected_version_vector = &segment.to_version_vector; + } + if self + .assets + .iter() + .any(|asset| asset.content_hash != blake3_hash(&asset.bytes) || asset.byte_length != asset.bytes.len() as u64) + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "asset hash or length mismatch")); + } + self.validate_manifest_indexes()?; + self.validate_integrity_index()?; + self.validate_schema_migrations()?; + self.load_loro_doc_unvalidated()?; + Ok(()) + } + + pub fn rebuild_projection_cache_from_loro(&mut self, doc: &LoroDoc) -> io::Result<()> { + doc.commit(); + let frontier = encode_frontiers(&doc.state_frontiers()); + let projection = crate::loro_projection::projection_blocks_from_loro(doc)?; + self.projection_caches.clear(); + self.projection_caches.push(ProjectionCacheChunk { + frontier: frontier.clone(), + bytes: encode_chunk(&projection, "projection cache payload")?, + }); + self.manifest.projection_cache_frontier = Some(frontier); + self.manifest.modified_at_unix_secs = unix_time_secs(); + self.validate()?; + Ok(()) + } + + pub fn current_projection_document(&self) -> io::Result> { + let Some(frontier) = self.manifest.projection_cache_frontier.as_deref() else { + return Ok(None); + }; + if frontier != self.manifest.latest_frontier.as_slice() { + return Ok(None); + } + let Some(cache) = self + .projection_caches + .iter() + .find(|cache| cache.frontier == frontier) + else { + return Ok(None); + }; + let projection = decode_chunk::(&cache.bytes, "projection cache payload")?; + let mut document = crate::loro_projection::document_from_projection_blocks(projection); + if document.ids.document_id == 0 { + document.ids.document_id = self.manifest.document_id; + } + document.frontier = frontier.to_vec(); + Ok(Some(document)) + } + + fn validate_manifest_indexes(&self) -> io::Result<()> { + if self.manifest.update_segment_index.len() != self.loro_update_segments.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "manifest update segment index length mismatch", + )); + } + for (index, segment) in self + .manifest + .update_segment_index + .iter() + .zip(&self.loro_update_segments) + { + if index.id != segment.segment_id || index.checksum != segment.checksum || index.byte_length != segment.bytes.len() as u64 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "manifest update segment index mismatch")); + } + } + if self.manifest.asset_index.len() != self.assets.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "manifest asset index length mismatch")); + } + for (index, asset) in self.manifest.asset_index.iter().zip(&self.assets) { + if index.id != asset.asset_id || index.checksum != asset.content_hash || index.byte_length != asset.byte_length { + return Err(io::Error::new(io::ErrorKind::InvalidData, "manifest asset index mismatch")); + } + } + Ok(()) + } + + /// §19: cross-check the named integrity index against the actual durable + /// chunks. An absent index (older packages) is accepted for backward + /// compatibility; a present index must be complete and consistent. + fn validate_integrity_index(&self) -> io::Result<()> { + // §19: an empty integrity index is the legacy compatibility case for older + // packages that predate the named index. Preserve that behavior explicitly. + if self.integrity_index.is_empty() { + return Ok(()); + } + let expected = self.build_integrity_index(); + if self.integrity_index.len() != expected.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "integrity index entry count mismatch")); + } + let expected = Self::integrity_index_key_set(&expected)?; + let actual = Self::integrity_index_key_set(&self.integrity_index)?; + if actual != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "integrity index entries do not match durable chunks", + )); + } + for entry in &self.integrity_index { + let actual = match entry.chunk_kind { + CHUNK_LORO_SNAPSHOT => self + .loro_snapshots + .iter() + .find(|snapshot| snapshot.snapshot_id == entry.id) + .map(|snapshot| (blake3_hash(&snapshot.bytes), snapshot.bytes.len() as u64)), + CHUNK_LORO_UPDATE_SEGMENT => self + .loro_update_segments + .iter() + .find(|segment| segment.segment_id == entry.id) + .map(|segment| (segment.checksum, segment.bytes.len() as u64)), + CHUNK_ASSET => self + .assets + .iter() + .find(|asset| asset.asset_id == entry.id) + .map(|asset| (asset.content_hash, asset.byte_length)), + _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "integrity index has an unknown chunk kind")), + }; + let Some((checksum, byte_length)) = actual else { + return Err(io::Error::new(io::ErrorKind::InvalidData, "integrity index references a missing chunk")); + }; + if entry.checksum != checksum || entry.byte_length != byte_length { + return Err(io::Error::new(io::ErrorKind::InvalidData, "integrity index checksum or length mismatch")); + } + } + Ok(()) + } + + /// §27: validate the schema migration log. Empty at schema v1. Each record + /// must describe a forward migration whose target does not exceed this + /// package's schema version. + fn validate_schema_migrations(&self) -> io::Result<()> { + for migration in &self.manifest.schema_migrations { + if migration.from_version >= migration.to_version || migration.to_version > self.manifest.loro_schema_version { + return Err(io::Error::new(io::ErrorKind::InvalidData, "inconsistent schema migration record")); + } + } + Ok(()) + } + + /// §27: record a schema migration event in package metadata. The migration's + /// Loro document mutation (if any) must be committed separately as an ordinary + /// Loro change with the `"migration"` origin before/after calling this. + pub fn record_schema_migration(&mut self, from_version: u32, to_version: u32, description: impl Into) -> io::Result { + if from_version >= to_version || to_version > self.manifest.loro_schema_version { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "schema migration must move forward to a version supported by this package", + )); + } + let migration_id = Uuid::new_v4().as_u128(); + self.manifest.schema_migrations.push(SchemaMigrationRecord { + id: migration_id, + from_version, + to_version, + applied_at_unix_secs: unix_time_secs(), + description: description.into(), + }); + self.manifest.modified_at_unix_secs = unix_time_secs(); + self.validate()?; + Ok(migration_id) + } + + fn latest_snapshot(&self) -> Option<&LoroSnapshotChunk> { + self + .loro_snapshots + .iter() + .find(|snapshot| snapshot.snapshot_id == self.manifest.latest_snapshot_id) + } + + fn snapshot_for_frontier(&self, frontier: &[u8]) -> Option<&LoroSnapshotChunk> { + self + .loro_snapshots + .iter() + .find(|snapshot| snapshot.frontier == frontier) + } + + fn with_manifest_indexes(mut self) -> io::Result { + self.refresh_manifest_indexes(); + Ok(self) + } + + /// Rebuild the manifest's update-segment/asset indexes and the §19 integrity + /// index from the package's current durable chunks. Infallible and idempotent. + fn refresh_manifest_indexes(&mut self) { + let mut update_segment_index = Vec::with_capacity(self.loro_update_segments.len()); + for segment in &self.loro_update_segments { + update_segment_index.push(ChunkRef { + id: segment.segment_id, + checksum: segment.checksum, + byte_length: segment.bytes.len() as u64, + }); + } + let mut asset_index = Vec::with_capacity(self.assets.len()); + for asset in &self.assets { + asset_index.push(ChunkRef { + id: asset.asset_id, + checksum: asset.content_hash, + byte_length: asset.byte_length, + }); + } + self.manifest.update_segment_index = update_segment_index; + self.manifest.asset_index = asset_index; + self.integrity_index = self.build_integrity_index(); + } + + /// §19: build a complete integrity index covering every durable chunk — Loro + /// snapshots, update segments, and assets — with each chunk's id, kind, BLAKE3 + /// checksum, and byte length. + fn build_integrity_index(&self) -> Vec { + let mut entries = Vec::with_capacity(self.loro_snapshots.len() + self.loro_update_segments.len() + self.assets.len()); + for snapshot in &self.loro_snapshots { + entries.push(IntegrityIndexEntry { + chunk_kind: CHUNK_LORO_SNAPSHOT, + id: snapshot.snapshot_id, + checksum: blake3_hash(&snapshot.bytes), + byte_length: snapshot.bytes.len() as u64, + }); + } + for segment in &self.loro_update_segments { + entries.push(IntegrityIndexEntry { + chunk_kind: CHUNK_LORO_UPDATE_SEGMENT, + id: segment.segment_id, + checksum: segment.checksum, + byte_length: segment.bytes.len() as u64, + }); + } + for asset in &self.assets { + entries.push(IntegrityIndexEntry { + chunk_kind: CHUNK_ASSET, + id: asset.asset_id, + checksum: asset.content_hash, + byte_length: asset.byte_length, + }); + } + entries + } + + fn integrity_index_key_set(entries: &[IntegrityIndexEntry]) -> io::Result> { + let mut keys = HashSet::with_capacity(entries.len()); + for entry in entries { + if !keys.insert((entry.chunk_kind, entry.id)) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "integrity index contains duplicate chunk identity", + )); + } + } + Ok(keys) + } +} + +pub fn read_loro_db8(path: impl AsRef) -> io::Result { + DocumentPackage::read(path)?.load_loro_doc() +} + +pub fn write_loro_db8(path: impl AsRef, doc: &LoroDoc, title: &str) -> io::Result<()> { + DocumentPackage::from_loro_snapshot(doc, title)?.write(path) +} + +pub fn loro_db8_bytes(doc: &LoroDoc, title: &str) -> io::Result> { + DocumentPackage::from_loro_snapshot(doc, title)?.to_bytes() +} + +fn read_chunks(bytes: &[u8]) -> io::Result> { + let mut cursor = Cursor::new(bytes); + let mut magic = [0_u8; 16]; + cursor.read_exact(&mut magic)?; + if &magic != PACKAGE_MAGIC { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid Flowstate Loro package magic")); + } + let version = read_u32(&mut cursor)?; + if version != PACKAGE_HEADER_VERSION { + return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported Flowstate package header version")); + } + let chunk_count = read_u32(&mut cursor)?; + if usize::try_from(chunk_count).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "package chunk count overflows usize"))? + > MAX_PACKAGE_CHUNK_COUNT + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "too many package chunks")); + } + let mut entries = Vec::with_capacity(chunk_count as usize); + for _ in 0..chunk_count { + let kind = read_u32(&mut cursor)?; + let offset = read_u64(&mut cursor)?; + let len = read_u64(&mut cursor)?; + let mut checksum = [0_u8; 32]; + cursor.read_exact(&mut checksum)?; + entries.push(ChunkEntry { kind, offset, len, checksum }); + } + let mut chunks = Vec::with_capacity(entries.len()); + for entry in entries { + let start = usize::try_from(entry.offset).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "chunk offset overflows usize"))?; + let len = usize::try_from(entry.len).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "chunk length overflows usize"))?; + if len > MAX_PACKAGE_CHUNK_COUNT.saturating_mul(PACKAGE_CHUNK_TABLE_ENTRY_BYTES) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "package chunk length is unreasonably large")); + } + let end = start + .checked_add(len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "chunk range overflows usize"))?; + if end > bytes.len() { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "Flowstate package chunk is truncated")); + } + let payload = bytes[start..end].to_vec(); + if blake3_hash(&payload) != entry.checksum { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Flowstate package chunk checksum mismatch")); + } + chunks.push(Chunk { kind: entry.kind, payload }); + } + Ok(chunks) +} + +fn cached_search_units_from_compact_bytes(bytes: &[u8]) -> io::Result<(DocumentPackageManifest, Vec)> { + let chunks = read_chunks(bytes)?; + let mut manifest = None; + let mut search_units = Vec::new(); + for chunk in chunks { + match chunk.kind { + CHUNK_MANIFEST => manifest = Some(decode_chunk(&chunk.payload, "manifest")?), + CHUNK_SEARCH_UNIT => search_units.push(decode_chunk(&chunk.payload, "search unit")?), + _ => {}, + } + } + let manifest = manifest.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate package has no manifest"))?; + Ok((manifest, search_units)) +} + +fn cached_search_units_from_journal_bytes(bytes: &[u8]) -> io::Result<(DocumentPackageManifest, Vec)> { + let mut cached = None; + for payload in committed_journal_payloads(bytes)? { + if payload.starts_with(PACKAGE_MAGIC) { + cached = Some(cached_search_units_from_compact_bytes(payload)?); + continue; + } + let delta = decode_journal_delta(payload)?; + let Some((manifest, search_units)) = cached.as_mut() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Flowstate package journal delta precedes a full generation", + )); + }; + match delta { + PackageJournalDelta::Update { manifest: next_manifest, .. } | PackageJournalDelta::Assets { manifest: next_manifest, .. } => { + *manifest = next_manifest; + if manifest.search_cache_frontier.is_none() { + search_units.clear(); + } + }, + } + } + cached.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate package journal has no complete full generation")) +} + +fn write_chunks(chunks: &[Chunk]) -> io::Result> { + let table_len = chunks.len() * (4 + 8 + 8 + 32); + let header_len = PACKAGE_MAGIC.len() + 4 + 4 + table_len; + let payload_len = chunks + .iter() + .map(|chunk| chunk.payload.len()) + .sum::(); + let mut bytes = Vec::with_capacity(header_len + payload_len); + bytes.extend_from_slice(PACKAGE_MAGIC); + write_u32(&mut bytes, PACKAGE_HEADER_VERSION); + write_u32( + &mut bytes, + u32::try_from(chunks.len()).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "too many package chunks"))?, + ); + let mut offset = header_len; + for chunk in chunks { + write_u32(&mut bytes, chunk.kind); + write_u64(&mut bytes, offset as u64); + write_u64(&mut bytes, chunk.payload.len() as u64); + bytes.extend_from_slice(&blake3_hash(&chunk.payload)); + offset += chunk.payload.len(); + } + for chunk in chunks { + bytes.extend_from_slice(&chunk.payload); + } + Ok(bytes) +} + +fn encode_chunk(value: &T, label: &'static str) -> io::Result> { + postcard::to_stdvec(value).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("encoding {label} failed: {error}"))) +} + +fn decode_chunk<'a, T: Deserialize<'a>>(bytes: &'a [u8], label: &'static str) -> io::Result { + if bytes.len() > MAX_PACKAGE_CHUNK_COUNT.saturating_mul(PACKAGE_CHUNK_TABLE_ENTRY_BYTES) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("decoding {label} failed: chunk too large"), + )); + } + postcard::from_bytes(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("decoding {label} failed: {error}"))) +} + +fn encode_frontiers(frontiers: &Frontiers) -> Vec { + frontiers.encode() +} + +fn decode_frontiers(bytes: &[u8]) -> io::Result { + Frontiers::decode(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("decoding frontiers failed: {error}"))) +} + +fn encode_version_vector(version_vector: &VersionVector) -> Vec { + version_vector.encode() +} + +fn validate_frontiers(bytes: &[u8], label: &'static str) -> io::Result<()> { + Frontiers::decode(bytes) + .map(|_| ()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("decoding {label} failed: {error}"))) +} + +fn validate_version_vector(bytes: &[u8], label: &'static str) -> io::Result<()> { + VersionVector::decode(bytes) + .map(|_| ()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("decoding {label} failed: {error}"))) +} + +fn blake3_hash(bytes: &[u8]) -> [u8; 32] { + *blake3::hash(bytes).as_bytes() +} + +fn loro_io_error(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) +} + +fn unix_time_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) +} + +fn read_u32(cursor: &mut Cursor<&[u8]>) -> io::Result { + let mut bytes = [0; 4]; + cursor.read_exact(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) +} + +fn write_u32(bytes: &mut Vec, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn read_u64(cursor: &mut Cursor<&[u8]>) -> io::Result { + let mut bytes = [0; 8]; + cursor.read_exact(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) +} + +fn write_u64(bytes: &mut Vec, value: u64) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn file_has_journal_header(path: &Path) -> io::Result { + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let mut magic = [0_u8; 16]; + match file.read_exact(&mut magic) { + Ok(()) => Ok(&magic == JOURNAL_MAGIC), + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false), + Err(error) => Err(error), + } +} + +fn append_journal_transaction(path: &Path, payload: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let existing = fs::read(path)?; + let (_, committed_end) = committed_journal_transactions(&existing)?; + let mut file = OpenOptions::new().read(true).write(true).open(path)?; + if committed_end != existing.len() { + file.set_len(committed_end as u64)?; + } + file.seek(SeekFrom::End(0))?; + let mut bytes = Vec::with_capacity(journal_transaction_len(payload.len())); + append_journal_transaction_bytes(&mut bytes, payload); + file.write_all(&bytes)?; + file.sync_all() +} + +fn append_journal_transaction_to_prepared_file(path: &Path, payload: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let mut file = OpenOptions::new().append(true).open(path)?; + let mut bytes = Vec::with_capacity(journal_transaction_len(payload.len())); + append_journal_transaction_bytes(&mut bytes, payload); + file.write_all(&bytes)?; + file.sync_all() +} + +fn append_journal_transaction_bytes(bytes: &mut Vec, payload: &[u8]) { + bytes.extend_from_slice(JOURNAL_TXN_MAGIC); + write_u64(bytes, payload.len() as u64); + bytes.extend_from_slice(&blake3_hash(payload)); + bytes.extend_from_slice(payload); + bytes.extend_from_slice(JOURNAL_COMMIT_MAGIC); +} + +fn journal_transaction_len(payload_len: usize) -> usize { + JOURNAL_TXN_MAGIC.len() + 8 + 32 + payload_len + JOURNAL_COMMIT_MAGIC.len() +} + +fn committed_journal_payloads(bytes: &[u8]) -> io::Result> { + committed_journal_transactions(bytes).map(|(payloads, _)| payloads) +} + +fn committed_journal_transactions(bytes: &[u8]) -> io::Result<(Vec<&[u8]>, usize)> { + if bytes.len() < JOURNAL_MAGIC.len() + 4 || !bytes.starts_with(JOURNAL_MAGIC) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid Flowstate package journal header")); + } + let mut cursor = Cursor::new(&bytes[JOURNAL_MAGIC.len()..]); + let version = read_u32(&mut cursor)?; + if version != JOURNAL_HEADER_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported Flowstate package journal version", + )); + } + let mut offset = JOURNAL_MAGIC.len() + 4; + let mut committed = Vec::new(); + while offset < bytes.len() { + let fixed_len = JOURNAL_TXN_MAGIC.len() + 8 + 32; + if bytes.len().saturating_sub(offset) < fixed_len { + break; + } + if &bytes[offset..offset + JOURNAL_TXN_MAGIC.len()] != JOURNAL_TXN_MAGIC { + break; + } + offset += JOURNAL_TXN_MAGIC.len(); + let payload_len = u64::from_le_bytes( + bytes[offset..offset + 8] + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid Flowstate journal transaction length"))?, + ); + offset += 8; + let checksum: [u8; 32] = bytes[offset..offset + 32] + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid Flowstate journal transaction checksum"))?; + offset += 32; + let payload_len = usize::try_from(payload_len) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Flowstate journal transaction length overflows usize"))?; + let payload_end = match offset.checked_add(payload_len) { + Some(end) => end, + None => break, + }; + let commit_end = match payload_end.checked_add(JOURNAL_COMMIT_MAGIC.len()) { + Some(end) => end, + None => break, + }; + if commit_end > bytes.len() { + break; + } + if &bytes[payload_end..commit_end] != JOURNAL_COMMIT_MAGIC { + break; + } + let payload = &bytes[offset..payload_end]; + if blake3_hash(payload) != checksum { + break; + } + committed.push(payload); + offset = commit_end; + } + if committed.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Flowstate package journal has no complete transaction", + )); + } + Ok((committed, offset)) +} + +fn write_journal_generation(path: &Path, payload: &[u8]) -> io::Result<()> { + let mut bytes = Vec::with_capacity(JOURNAL_MAGIC.len() + 4 + journal_transaction_len(payload.len())); + bytes.extend_from_slice(JOURNAL_MAGIC); + write_u32(&mut bytes, JOURNAL_HEADER_VERSION); + append_journal_transaction_bytes(&mut bytes, payload); + write_bytes_atomic(path, &bytes) +} + +fn encode_journal_delta(delta: &PackageJournalDelta) -> io::Result> { + let encoded = encode_chunk(delta, "package journal delta")?; + let mut payload = Vec::with_capacity(JOURNAL_DELTA_MAGIC.len() + encoded.len()); + payload.extend_from_slice(JOURNAL_DELTA_MAGIC); + payload.extend_from_slice(&encoded); + Ok(payload) +} + +fn decode_journal_delta(payload: &[u8]) -> io::Result { + let encoded = payload + .strip_prefix(JOURNAL_DELTA_MAGIC) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "unknown Flowstate package journal transaction"))?; + decode_chunk(encoded, "package journal delta") +} + +fn package_map_string(map: &loro::LoroMap, key: &str) -> Option { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + } +} + +fn loro_revision_exists(doc: &LoroDoc, revision_id: u128) -> bool { + let root = doc.get_map(crate::loro_schema::ROOT); + let Some(ValueOrContainer::Container(Container::List(revisions))) = root.get(crate::loro_schema::REVISIONS) else { + return false; + }; + for index in 0..revisions.len() { + let Some(ValueOrContainer::Container(Container::Map(revision))) = revisions.get(index) else { + continue; + }; + if package_map_string(&revision, "id").and_then(|id| id.parse::().ok()) == Some(revision_id) { + return true; + } + } + false +} + +fn package_map_binary(map: &loro::LoroMap, key: &str) -> Option> { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::Binary(value)) => Some(value.to_vec()), + _ => None, + } +} + +fn package_map_i64(map: &loro::LoroMap, key: &str) -> Option { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::I64(value)) => Some(value), + _ => None, + } +} + +fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + atomicwrites::AtomicFile::new(path, atomicwrites::AllowOverwrite) + .write(|file| file.write_all(bytes)) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + AssetId, AssetRecord, Block, InputBlock, InputBlockAlignment, InputEquationBlock, InputEquationDisplay, InputEquationSyntax, + InputImageBlock, InputImageSizing, InputParagraph, InputRun, InputTableBlock, InputTableCell, InputTableCellBlock, InputTableColumnWidth, + InputTableRow, InputTableStyle, RunStyles, TableCellBlock, document_from_loro, document_to_loro, + loro_schema::{body_text, new_loro_document}, + read_db8_bytes, + }; + use loro::{Container, LoroDoc, LoroMap, LoroValue, ValueOrContainer}; + + #[test] + fn package_roundtrips_loro_snapshot() -> io::Result<()> { + let doc = new_loro_document("Roundtrip").map_err(loro_test_error)?; + let text = body_text(&doc); + text + .insert(text.len_unicode(), "Hello Loro") + .map_err(loro_test_error)?; + let bytes = loro_db8_bytes(&doc, "Roundtrip")?; + + let package = DocumentPackage::from_bytes(&bytes)?; + assert_eq!(package.manifest.package_format_version, LORO_PACKAGE_FORMAT_VERSION); + assert_eq!(package.manifest.loro_schema_version, LORO_SCHEMA_VERSION); + assert_eq!(package.loro_snapshots.len(), 2); + assert_eq!( + package.manifest.projection_cache_frontier.as_deref(), + Some(package.manifest.latest_frontier.as_slice()) + ); + assert_eq!(package.projection_caches.len(), 1); + + let loaded = package.load_loro_doc()?; + assert_eq!(body_text(&loaded).to_string(), "\nHello Loro"); + let projected = package + .current_projection_document()? + .expect("projection cache"); + assert_eq!(crate::paragraph_text(&projected, 0), "Hello Loro"); + Ok(()) + } + + #[test] + fn package_read_repairs_an_incomplete_journal_tail() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("tail-recovery.db8"); + let doc = new_loro_document("Tail recovery").map_err(loro_test_error)?; + let package = DocumentPackage::from_loro_snapshot(&doc, "Tail recovery")?; + package.write(&path)?; + let committed_len = fs::metadata(&path)?.len(); + + let mut file = OpenOptions::new().append(true).open(&path)?; + std::io::Write::write_all(&mut file, b"incomplete journal transaction")?; + drop(file); + assert!(fs::metadata(&path)?.len() > committed_len); + + let repaired = DocumentPackage::read(&path)?; + + repaired.validate()?; + assert_eq!(fs::metadata(&path)?.len(), committed_len); + Ok(()) + } + + #[test] + fn package_rejects_old_final_state_magic() { + let old_bytes = b"GPTX\x06\0\0\0old-format"; + let error = DocumentPackage::from_bytes(old_bytes).expect_err("old final-state bytes must not load"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn public_db8_reader_rejects_old_final_state_magic() { + let old_bytes = b"GPTX\x06\0\0\0old-format"; + let error = read_db8_bytes(old_bytes).expect_err("old final-state bytes must not load through public facade"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn public_db8_bytes_roundtrip_through_loro_package() -> io::Result<()> { + let source = crate::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![InputBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "Hello package".to_string(), + styles: RunStyles::default(), + }], + })], + ); + let doc = document_to_loro(&source, "Public facade").map_err(loro_test_error)?; + let bytes = + DocumentPackage::from_loro_snapshot_with_assets(&doc, "Public facade", crate::loro_import::assets_from_document(&source))?.to_bytes()?; + let package = DocumentPackage::from_bytes(&bytes)?; + assert_eq!(package.manifest.package_format_version, LORO_PACKAGE_FORMAT_VERSION); + let projected = read_db8_bytes(&bytes)?; + assert_eq!(crate::paragraph_text(&projected, 0), "Hello package"); + Ok(()) + } + + #[test] + fn package_loads_snapshot_plus_update_segment() -> io::Result<()> { + let doc = new_loro_document("Append").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Append")?; + let from_frontier = doc.state_frontiers(); + let from_vv = doc.state_vv(); + + let text = body_text(&doc); + text + .insert(text.len_unicode(), "after save") + .map_err(loro_test_error)?; + doc.commit(); + let update = doc + .export(ExportMode::updates(&from_vv)) + .map_err(loro_test_error)?; + package.append_update_segment(&from_frontier, &from_vv, &doc.state_frontiers(), &doc.state_vv(), update)?; + assert!(package.manifest.projection_cache_frontier.is_none()); + assert!(package.projection_caches.is_empty()); + + let bytes = package.to_bytes()?; + let loaded = DocumentPackage::from_bytes(&bytes)?.load_loro_doc()?; + assert_eq!(body_text(&loaded).to_string(), "\nafter save"); + Ok(()) + } + + #[test] + fn package_rejects_manifest_projection_cache_frontier_without_chunk() -> io::Result<()> { + let doc = new_loro_document("Projection cache").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Projection cache")?; + package.projection_caches.clear(); + + let error = package + .validate() + .expect_err("manifest projection cache frontier must point at a cache chunk"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + Ok(()) + } + + #[test] + fn package_rejects_manifest_update_segment_index_mismatch() -> io::Result<()> { + let doc = new_loro_document("Append").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Append")?; + let from_frontier = doc.state_frontiers(); + let from_vv = doc.state_vv(); + + body_text(&doc) + .insert(1, "update") + .map_err(loro_test_error)?; + doc.commit(); + let update = doc + .export(ExportMode::updates(&from_vv)) + .map_err(loro_test_error)?; + package.append_update_segment(&from_frontier, &from_vv, &doc.state_frontiers(), &doc.state_vv(), update)?; + package.manifest.update_segment_index[0].byte_length = 0; + + let error = package + .validate() + .expect_err("stale manifest segment index must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + Ok(()) + } + + #[test] + fn package_rejects_duplicate_integrity_index_entries() -> io::Result<()> { + let doc = new_loro_document("Integrity").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Integrity")?; + package.integrity_index[1].chunk_kind = package.integrity_index[0].chunk_kind; + package.integrity_index[1].id = package.integrity_index[0].id; + + let error = package + .validate() + .expect_err("duplicate integrity entries must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + Ok(()) + } + + #[test] + fn package_replace_assets_is_failure_atomic() -> io::Result<()> { + let source = crate::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![InputBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "asset".to_string(), + styles: RunStyles::default(), + }], + })], + ); + let doc = document_to_loro(&source, "Atomic assets").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot_with_assets(&doc, "Atomic assets", crate::loro_import::assets_from_document(&source))?; + let previous_modified_at = package.manifest.modified_at_unix_secs; + package.manifest.package_format_version = 0; + + let error = package + .replace_assets_from_document(&source) + .expect_err("invalid package version must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(package.manifest.modified_at_unix_secs, previous_modified_at); + assert_eq!(package.manifest.package_format_version, 0); + Ok(()) + } + + #[test] + fn package_append_update_segment_is_failure_atomic() -> io::Result<()> { + let doc = new_loro_document("Atomic update").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Atomic update")?; + let from_frontier = doc.state_frontiers(); + let from_vv = doc.state_vv(); + body_text(&doc).insert(1, "x").map_err(loro_test_error)?; + doc.commit(); + let update = doc + .export(ExportMode::updates(&from_vv)) + .map_err(loro_test_error)?; + let previous_segments = package.loro_update_segments.len(); + let previous_latest_frontier = package.manifest.latest_frontier.clone(); + package.manifest.package_format_version = 0; + + let error = package + .append_update_segment(&from_frontier, &from_vv, &doc.state_frontiers(), &doc.state_vv(), update) + .expect_err("invalid package version must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(package.loro_update_segments.len(), previous_segments); + assert_eq!(package.manifest.latest_frontier, previous_latest_frontier); + assert_eq!(package.manifest.package_format_version, 0); + Ok(()) + } + + #[test] + fn package_compact_to_snapshot_is_failure_atomic() -> io::Result<()> { + let doc = new_loro_document("Atomic compact").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Atomic compact")?; + let previous_snapshots = package.loro_snapshots.len(); + let previous_update_segments = package.loro_update_segments.len(); + package.manifest.package_format_version = 0; + + let error = package + .compact_to_snapshot(&doc) + .expect_err("invalid package version must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(package.loro_snapshots.len(), previous_snapshots); + assert_eq!(package.loro_update_segments.len(), previous_update_segments); + assert_eq!(package.manifest.package_format_version, 0); + Ok(()) + } + + #[test] + fn package_rejects_manifest_asset_index_mismatch() -> io::Result<()> { + let doc = new_loro_document("Assets").map_err(loro_test_error)?; + let bytes = b"asset bytes".to_vec(); + let asset = AssetChunk { + asset_id: 7, + content_hash: blake3_hash(&bytes), + mime_type: "application/octet-stream".to_string(), + byte_length: bytes.len() as u64, + bytes, + metadata: Vec::new(), + }; + let mut package = DocumentPackage::from_loro_snapshot_with_assets(&doc, "Assets", vec![asset])?; + package.manifest.asset_index[0].checksum = [0; 32]; + + let error = package + .validate() + .expect_err("stale manifest asset index must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + Ok(()) + } + + #[test] + fn named_revisions_are_restorable_after_compaction() -> io::Result<()> { + let doc = new_loro_document("Revisions").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Revisions")?; + let first_revision = package.create_named_revision(&doc, "Blank", "Before edits", None, None)?; + + let from_frontier = doc.state_frontiers(); + let from_vv = doc.state_vv(); + let text = body_text(&doc); + text.insert(1, "after").map_err(loro_test_error)?; + doc.commit(); + let update = doc + .export(ExportMode::updates(&from_vv)) + .map_err(loro_test_error)?; + package.append_update_segment(&from_frontier, &from_vv, &doc.state_frontiers(), &doc.state_vv(), update)?; + assert!(package.current_search_units().is_empty()); + let second_revision = package.create_named_revision(&doc, "After", "After text insert", None, None)?; + + package.compact_to_snapshot(&doc)?; + + let first_doc = package.load_revision_loro_doc(first_revision)?; + assert_eq!(body_text(&first_doc).to_string(), "\n"); + let second_doc = package.load_revision_loro_doc(second_revision)?; + assert_eq!(body_text(&second_doc).to_string(), "\nafter"); + let latest_doc = package.load_loro_doc()?; + assert_eq!(body_text(&latest_doc).to_string(), "\nafter"); + Ok(()) + } + + #[test] + fn package_compacts_update_segments_after_threshold() -> io::Result<()> { + let doc = new_loro_document("Auto compact").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Auto compact")?; + let initial_revision = package.create_named_revision(&doc, "Blank", "Before edits", None, None)?; + + for text in ["one", " two"] { + let from_frontier = doc.state_frontiers(); + let from_vv = doc.state_vv(); + body_text(&doc) + .insert(body_text(&doc).len_unicode(), text) + .map_err(loro_test_error)?; + doc.commit(); + let update = doc + .export(ExportMode::updates(&from_vv)) + .map_err(loro_test_error)?; + package.append_update_segment(&from_frontier, &from_vv, &doc.state_frontiers(), &doc.state_vv(), update)?; + } + + // Creating the named revision records it into the Loro `revisions` list and + // persists that op as its own update segment (Loro-native revisions), so the + // two body-text inserts bring the total to three segments. + assert_eq!(package.loro_update_segments.len(), 3); + let snapshot_id = package.compact_update_segments_if_needed(&doc, 1)?; + assert!(snapshot_id.is_some()); + assert!(package.loro_update_segments.is_empty()); + + let latest_doc = package.load_loro_doc()?; + assert_eq!(body_text(&latest_doc).to_string(), "\none two"); + let revision_doc = package.load_revision_loro_doc(initial_revision)?; + assert_eq!(body_text(&revision_doc).to_string(), "\n"); + Ok(()) + } + + #[test] + fn package_rebuilds_search_units_from_loro_body_flow() -> io::Result<()> { + let doc = new_loro_document("Search").map_err(loro_test_error)?; + let text = body_text(&doc); + text.insert(1, "Alpha\nBeta").map_err(loro_test_error)?; + doc.commit(); + + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Search")?; + package.rebuild_search_units_from_loro(&doc)?; + + assert_eq!(package.search_units.len(), 2); + assert_eq!(package.search_units[0].body, "Alpha"); + assert_eq!(package.search_units[1].body, "Beta"); + assert_eq!( + package.manifest.search_cache_frontier.as_deref(), + Some(package.manifest.latest_frontier.as_slice()) + ); + assert!(!package.search_units[0].paragraph_start_cursor.is_empty()); + assert!(!package.search_units[0].paragraph_end_cursor.is_empty()); + Ok(()) + } + + #[test] + fn table_cells_use_column_ids_and_project_by_column_order() -> io::Result<()> { + let source = crate::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![InputBlock::Table(InputTableBlock { + rows: vec![InputTableRow { + cells: vec![ + InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "left".to_string(), + styles: RunStyles::default(), + }], + })], + row_span: 1, + col_span: 1, + }, + InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "right".to_string(), + styles: RunStyles::default(), + }], + })], + row_span: 1, + col_span: 1, + }, + ], + }], + column_widths: vec![InputTableColumnWidth::Auto, InputTableColumnWidth::Auto], + style: InputTableStyle { header_row: false }, + })], + ); + let doc = document_to_loro(&source, "Table schema")?; + let table_owner = first_table_owner(&doc)?; + let table = test_child_map(&table_owner, "table")?; + let column_ids = test_ordered_ids(&table, "column_order")?; + assert_eq!(column_ids.len(), 2); + + let cells_by_id = test_child_map(&table, "cells_by_id")?; + let mut seen_column_ids = Vec::new(); + for cell_id in cells_by_id.keys().map(|key| key.to_string()) { + let cell = test_child_map(&cells_by_id, &cell_id)?; + assert!(cell.get("column_index").is_none()); + seen_column_ids.push(test_map_string(&cell, "column_id")?); + } + assert!(seen_column_ids.contains(&column_ids[0])); + assert!(seen_column_ids.contains(&column_ids[1])); + + let column_order = test_child_movable_list(&table, "column_order")?; + column_order.mov(0, 1).map_err(loro_test_error)?; + doc.commit(); + + let projected = document_from_loro(&doc)?; + let projected_table = projected + .blocks + .iter() + .find_map(|block| match block { + Block::Table(table) => Some(table), + _ => None, + }) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "projected table is missing"))?; + let cell_texts = projected_table.rows[0] + .cells + .iter() + .map(|cell| match &cell.blocks[0] { + TableCellBlock::Paragraph(paragraph) => paragraph.text.as_str(), + TableCellBlock::Table(_) => "", + }) + .collect::>(); + assert_eq!(cell_texts, vec!["right", "left"]); + Ok(()) + } + + #[test] + fn nested_tables_use_stable_list_refs_and_project_by_anchor_cursor() -> io::Result<()> { + let nested_table = InputTableBlock { + rows: vec![InputTableRow { + cells: vec![InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "inner".to_string(), + styles: RunStyles::default(), + }], + })], + row_span: 1, + col_span: 1, + }], + }], + column_widths: vec![InputTableColumnWidth::Auto], + style: InputTableStyle { header_row: false }, + }; + let source = crate::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![InputBlock::Table(InputTableBlock { + rows: vec![InputTableRow { + cells: vec![InputTableCell { + blocks: vec![ + InputTableCellBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "outer".to_string(), + styles: RunStyles::default(), + }], + }), + InputTableCellBlock::Table(nested_table), + ], + row_span: 1, + col_span: 1, + }], + }], + column_widths: vec![InputTableColumnWidth::Auto], + style: InputTableStyle { header_row: false }, + })], + ); + let doc = document_to_loro(&source, "Nested table")?; + let table = test_child_map(&first_table_owner(&doc)?, "table")?; + let cell = first_cell_map(&table)?; + assert_eq!(test_ordered_ids(&cell, "nested_table_ids")?.len(), 1); + assert!(test_child_map(&cell, "nested_tables_by_id").is_ok()); + assert!(!cell.keys().any(|key| key.starts_with("nested_table."))); + + let projected = document_from_loro(&doc)?; + let projected_table = projected + .blocks + .iter() + .find_map(|block| match block { + Block::Table(table) => Some(table), + _ => None, + }) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "projected table is missing"))?; + let blocks = &projected_table.rows[0].cells[0].blocks; + assert!(matches!(&blocks[0], TableCellBlock::Paragraph(paragraph) if paragraph.text == "outer")); + assert!( + matches!(&blocks[1], TableCellBlock::Table(table) if matches!(&table.rows[0].cells[0].blocks[0], TableCellBlock::Paragraph(paragraph) if paragraph.text == "inner")) + ); + Ok(()) + } + + #[test] + fn package_search_units_come_from_projection_objects_and_tables() -> io::Result<()> { + let source = crate::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![ + InputBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "Body".to_string(), + styles: RunStyles::default(), + }], + }), + InputBlock::Image(InputImageBlock { + asset_id: AssetId(7), + alt_text: "diagram alt".to_string(), + caption: Some(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "caption text".to_string(), + styles: RunStyles::default(), + }], + }), + sizing: InputImageSizing::FitWidth, + alignment: InputBlockAlignment::Left, + }), + InputBlock::Equation(InputEquationBlock { + source: "x^2".to_string(), + syntax: InputEquationSyntax::Latex, + display: InputEquationDisplay::Display, + }), + InputBlock::Table(InputTableBlock { + rows: vec![InputTableRow { + cells: vec![InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "cell text".to_string(), + styles: RunStyles::default(), + }], + })], + row_span: 1, + col_span: 1, + }], + }], + column_widths: vec![InputTableColumnWidth::Auto], + style: InputTableStyle { header_row: false }, + }), + ], + ); + let doc = document_to_loro(&source, "Search projection")?; + let image = first_block_owner_by_kind(&doc, "image")?; + let caption_flow_id = test_map_string(&image, "caption_flow_id")?; + let root = doc.get_map(crate::ROOT); + let flows = test_child_map(&root, crate::FLOWS_BY_ID)?; + let caption_flow = test_child_map(&flows, &caption_flow_id)?; + let caption_text = test_child_text(&caption_flow, crate::FLOW_TEXT_KEY)?; + caption_text + .insert(1, "caption text") + .map_err(loro_test_error)?; + doc.commit(); + let package = DocumentPackage::from_loro_snapshot(&doc, "Search projection")?; + let units = package.current_search_units(); + + assert!( + units + .iter() + .any(|unit| unit.unit_kind == "paragraph" && unit.body == "Body") + ); + assert!( + units + .iter() + .any(|unit| unit.unit_kind == "image_alt" && unit.body == "diagram alt") + ); + assert!( + units + .iter() + .any(|unit| unit.unit_kind == "image_caption" && unit.body == "caption text") + ); + assert!( + units + .iter() + .any(|unit| unit.unit_kind == "equation" && unit.body == "x^2") + ); + assert!( + units + .iter() + .any(|unit| unit.unit_kind == "table_cell" && unit.body == "cell text") + ); + assert!( + units + .iter() + .all(|unit| !unit.body.contains(crate::OBJECT_REPLACEMENT)) + ); + let body_unit = units + .iter() + .find(|unit| unit.unit_kind == "paragraph" && unit.body == "Body") + .expect("body paragraph search unit should exist"); + assert!(!body_unit.paragraph_start_cursor.is_empty()); + assert!(!body_unit.paragraph_end_cursor.is_empty()); + assert_eq!( + package.manifest.search_cache_frontier.as_deref(), + Some(package.manifest.latest_frontier.as_slice()) + ); + Ok(()) + } + + #[test] + fn public_db8_roundtrips_structured_loro_objects_and_assets() -> io::Result<()> { + let asset_id = AssetId(42); + let asset_bytes = b"not really a png".to_vec(); + let mut source = crate::document_from_input_blocks( + crate::flowstate_document_theme(), + vec![ + InputBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "before".to_string(), + styles: RunStyles::default(), + }], + }), + InputBlock::Image(InputImageBlock { + asset_id, + alt_text: "diagram alt".to_string(), + caption: Some(InputParagraph { + style: crate::ParagraphStyle::Custom(1), + runs: vec![InputRun { + text: "caption".to_string(), + styles: RunStyles::default(), + }], + }), + sizing: InputImageSizing::Fixed { + width_px: 320, + height_px: Some(180), + }, + alignment: InputBlockAlignment::Center, + }), + InputBlock::Equation(InputEquationBlock { + source: "x^2".to_string(), + syntax: InputEquationSyntax::Latex, + display: InputEquationDisplay::InlineLikeParagraph, + }), + InputBlock::Table(InputTableBlock { + rows: vec![InputTableRow { + cells: vec![InputTableCell { + blocks: vec![InputTableCellBlock::Paragraph(InputParagraph { + style: crate::ParagraphStyle::Normal, + runs: vec![InputRun { + text: "cell".to_string(), + styles: RunStyles::default(), + }], + })], + row_span: 1, + col_span: 1, + }], + }], + column_widths: vec![InputTableColumnWidth::FixedPx(144)], + style: InputTableStyle { header_row: true }, + }), + ], + ); + source.assets.assets.insert( + asset_id, + AssetRecord { + id: asset_id, + mime_type: "image/png".into(), + original_name: Some("diagram.png".into()), + content_hash: AssetRecord::stable_content_hash(&asset_bytes), + bytes: Arc::new(asset_bytes.clone()), + }, + ); + + let doc = document_to_loro(&source, "Structured").map_err(loro_test_error)?; + let bytes = + DocumentPackage::from_loro_snapshot_with_assets(&doc, "Structured", crate::loro_import::assets_from_document(&source))?.to_bytes()?; + let loaded = read_db8_bytes(&bytes)?; + assert_eq!(crate::paragraph_text(&loaded, 0), "before"); + assert_eq!( + loaded + .assets + .assets + .get(&asset_id) + .map(|asset| asset.bytes.as_ref().clone()), + Some(asset_bytes) + ); + + assert!(matches!( + &loaded.blocks[1], + Block::Image(image) + if image.asset_id == asset_id + && image.alt_text.as_ref() == "diagram alt" + && image.caption.as_ref().is_some_and(|caption| caption.style == crate::ParagraphStyle::Custom(1)) + )); + assert!(matches!( + &loaded.blocks[2], + Block::Equation(equation) + if equation.source.as_ref() == "x^2" && equation.display == crate::EquationDisplay::InlineLikeParagraph + )); + assert!(matches!( + &loaded.blocks[3], + Block::Table(table) + if table.style.header_row + && matches!(table.column_widths.as_slice(), [crate::TableColumnWidth::FixedPx(144)]) + && matches!(&table.rows[0].cells[0].blocks[0], TableCellBlock::Paragraph(paragraph) if paragraph.text == "cell") + )); + Ok(()) + } + + #[test] + fn package_serializes_empty_schema_migration_log() -> io::Result<()> { + let doc = new_loro_document("Migrations").map_err(loro_test_error)?; + let bytes = DocumentPackage::from_loro_snapshot(&doc, "Migrations")?.to_bytes()?; + let package = DocumentPackage::from_bytes(&bytes)?; + assert!(package.manifest.schema_migrations.is_empty()); + Ok(()) + } + + #[test] + fn package_rejects_inconsistent_schema_migration_record() -> io::Result<()> { + let doc = new_loro_document("Migrations").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Migrations")?; + package + .manifest + .schema_migrations + .push(SchemaMigrationRecord { + id: 1, + from_version: 2, + to_version: 1, + applied_at_unix_secs: 0, + description: "backwards".to_string(), + }); + + let error = package + .validate() + .expect_err("a backwards migration record must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + Ok(()) + } + + #[test] + fn package_integrity_index_covers_durable_chunks() -> io::Result<()> { + let doc = new_loro_document("Integrity").map_err(loro_test_error)?; + let bytes = b"asset bytes".to_vec(); + let asset = AssetChunk { + asset_id: 7, + content_hash: blake3_hash(&bytes), + mime_type: "application/octet-stream".to_string(), + byte_length: bytes.len() as u64, + bytes, + metadata: Vec::new(), + }; + let package = DocumentPackage::from_loro_snapshot_with_assets(&doc, "Integrity", vec![asset])?; + + assert!( + package + .integrity_index + .iter() + .any(|entry| entry.chunk_kind == CHUNK_ASSET && entry.id == 7) + ); + assert_eq!( + package + .integrity_index + .iter() + .filter(|entry| entry.chunk_kind == CHUNK_LORO_SNAPSHOT) + .count(), + package.loro_snapshots.len(), + ); + + let roundtrip = DocumentPackage::from_bytes(&package.to_bytes()?)?; + assert_eq!(roundtrip.integrity_index.len(), package.integrity_index.len()); + Ok(()) + } + + #[test] + fn package_rejects_tampered_integrity_index() -> io::Result<()> { + let doc = new_loro_document("Integrity").map_err(loro_test_error)?; + let mut package = DocumentPackage::from_loro_snapshot(&doc, "Integrity")?; + let entry = package + .integrity_index + .first_mut() + .expect("at least one integrity entry"); + entry.checksum = [0; 32]; + + let error = package + .validate() + .expect_err("a tampered integrity entry must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + Ok(()) + } + + fn first_table_owner(doc: &LoroDoc) -> io::Result { + first_block_owner_by_kind(doc, "table") + } + + fn first_block_owner_by_kind(doc: &LoroDoc, kind: &str) -> io::Result { + let root = doc.get_map(crate::ROOT); + let blocks = test_child_map(&root, crate::BLOCKS_BY_ID)?; + for block_id in blocks.keys().map(|key| key.to_string()) { + let block = test_child_map(&blocks, &block_id)?; + if test_map_string_opt(&block, "kind").as_deref() == Some(kind) { + return Ok(block); + } + } + Err(io::Error::new(io::ErrorKind::InvalidData, format!("{kind} block is missing"))) + } + + fn first_cell_map(table: &LoroMap) -> io::Result { + let cells_by_id = test_child_map(table, "cells_by_id")?; + let cell_id = cells_by_id + .keys() + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "table cell is missing"))? + .to_string(); + test_child_map(&cells_by_id, &cell_id) + } + + fn test_child_map(parent: &LoroMap, key: &str) -> io::Result { + match parent.get(key) { + Some(ValueOrContainer::Container(container)) => container + .into_map() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("`{key}` is not a map"))), + _ => Err(io::Error::new(io::ErrorKind::InvalidData, format!("missing map `{key}`"))), + } + } + + fn test_child_movable_list(parent: &LoroMap, key: &str) -> io::Result { + match parent.get(key) { + Some(ValueOrContainer::Container(Container::MovableList(list))) => Ok(list), + _ => Err(io::Error::new(io::ErrorKind::InvalidData, format!("missing movable list `{key}`"))), + } + } + + fn test_child_text(parent: &LoroMap, key: &str) -> io::Result { + match parent.get(key) { + Some(ValueOrContainer::Container(container)) => container + .into_text() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("`{key}` is not text"))), + _ => Err(io::Error::new(io::ErrorKind::InvalidData, format!("missing text `{key}`"))), + } + } + + fn test_ordered_ids(parent: &LoroMap, key: &str) -> io::Result> { + Ok( + test_child_movable_list(parent, key)? + .to_vec() + .into_iter() + .filter_map(|value| match value { + LoroValue::String(value) => Some(value.to_string()), + _ => None, + }) + .collect(), + ) + } + + fn test_map_string(map: &LoroMap, key: &str) -> io::Result { + test_map_string_opt(map, key).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("missing string `{key}`"))) + } + + fn test_map_string_opt(map: &LoroMap, key: &str) -> Option { + map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + }) + } + + fn loro_test_error(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) + } +} diff --git a/crates/flowstate-document/src/package_search.rs b/crates/flowstate-document/src/package_search.rs new file mode 100644 index 00000000..881a8322 --- /dev/null +++ b/crates/flowstate-document/src/package_search.rs @@ -0,0 +1,442 @@ +use std::io; + +use gpui_flowtext::{DocumentTheme, InputBlock, InputParagraph, ParagraphStyle, RunSemanticStyle}; +use loro::{Container, LoroDoc, LoroMap, LoroText, LoroValue, ValueOrContainer, cursor::Side}; + +use crate::{ + BLOCKS_BY_ID, FLOW_TEXT_KEY, FLOWS_BY_ID, OBJECT_REPLACEMENT, ROOT, ROOT_BODY_FLOW_ID, flowstate_document_theme, package::SearchUnitChunk, +}; + +pub(crate) fn search_units_from_loro(doc: &LoroDoc, document_id: u128, frontier: &[u8]) -> io::Result> { + let body = crate::loro_schema::body_text(doc); + let input_blocks = crate::loro_projection::input_blocks_from_loro(doc)?; + let mut builder = SearchUnitBuilder { + document_id, + frontier, + units: Vec::new(), + next_unit_ix: 0, + heading_path: Vec::new(), + body_paragraph_ranges: body_paragraph_cursor_ranges(&body), + body_paragraph_ix: 0, + theme: flowstate_document_theme(), + }; + for block in &input_blocks { + builder.push_block(block, &body); + } + builder.push_loro_object_units(doc)?; + Ok(builder.units) +} + +#[derive(Clone, Copy, Debug)] +struct BodyParagraphRange { + start: usize, + end: usize, +} + +struct SearchUnitBuilder<'a> { + document_id: u128, + frontier: &'a [u8], + units: Vec, + next_unit_ix: usize, + heading_path: Vec, + body_paragraph_ranges: Vec, + body_paragraph_ix: usize, + theme: DocumentTheme, +} + +impl SearchUnitBuilder<'_> { + fn push_block(&mut self, block: &InputBlock, body: &LoroText) { + match block { + InputBlock::Paragraph(paragraph) => self.push_body_paragraph(paragraph, body), + InputBlock::Image(_) | InputBlock::Equation(_) | InputBlock::Table(_) => {}, + } + } + + fn push_body_paragraph(&mut self, paragraph: &InputParagraph, body: &LoroText) { + let text = input_paragraph_text(paragraph); + let normalized = normalized_search_text(&text); + if !normalized.is_empty() + && let Some(level) = heading_level(&self.theme, paragraph.style) + { + self.update_heading_path(level, normalized.clone()); + } + let cursor_range = self + .body_paragraph_ranges + .get(self.body_paragraph_ix) + .copied(); + self.body_paragraph_ix += 1; + self.push_text_unit( + paragraph_unit_kind(paragraph), + &text, + SearchUnitRefs { + flow_id: Some(ROOT_BODY_FLOW_ID.to_string()), + cursors: cursor_range.map(|range| cursor_fields(body, range)), + paragraph_cursors: cursor_range.map(|range| cursor_fields(body, range)), + ..SearchUnitRefs::default() + }, + ); + } + + fn push_loro_object_units(&mut self, doc: &LoroDoc) -> io::Result<()> { + let root = doc.get_map(ROOT); + let Some(blocks) = child_map(&root, BLOCKS_BY_ID) else { + return Ok(()); + }; + let Some(flows) = child_map(&root, FLOWS_BY_ID) else { + return Ok(()); + }; + for block_id in map_keys(&blocks) { + let Some(block) = child_map(&blocks, &block_id) else { + continue; + }; + match map_string_opt(&block, "kind").as_deref() { + Some("image") => self.push_image_units(&flows, &block_id, &block)?, + Some("equation") => self.push_equation_units(&flows, &block_id, &block)?, + Some("table") => self.push_table_units(&flows, &block_id, None, &block)?, + _ => {}, + } + } + Ok(()) + } + + fn push_image_units(&mut self, flows: &LoroMap, block_id: &str, block: &LoroMap) -> io::Result<()> { + if let Some(flow_id) = map_string_opt(block, "alt_text_flow_id") { + self.push_flow_text_unit(flows, "image_alt", &flow_id, SearchUnitRefs::for_block(block_id))?; + } + if let Some(flow_id) = map_string_opt(block, "caption_flow_id") { + self.push_flow_text_unit(flows, "image_caption", &flow_id, SearchUnitRefs::for_block(block_id))?; + } + Ok(()) + } + + fn push_equation_units(&mut self, flows: &LoroMap, block_id: &str, block: &LoroMap) -> io::Result<()> { + if let Some(flow_id) = map_string_opt(block, "source_flow_id") { + self.push_flow_text_unit(flows, "equation", &flow_id, SearchUnitRefs::for_block(block_id))?; + } + Ok(()) + } + + fn push_table_units(&mut self, flows: &LoroMap, block_id: &str, _parent_cell_id: Option<&str>, owner: &LoroMap) -> io::Result<()> { + let Some(table) = child_map(owner, "table") else { + return Ok(()); + }; + let table_id = map_string_opt(owner, "id").unwrap_or_else(|| block_id.to_string()); + let Some(cells) = child_map(&table, "cells_by_id") else { + return Ok(()); + }; + for cell_id in map_keys(&cells) { + let Some(cell) = child_map(&cells, &cell_id) else { + continue; + }; + if let Some(flow_id) = map_string_opt(&cell, "flow_id") { + self.push_flow_text_unit( + flows, + "table_cell", + &flow_id, + SearchUnitRefs { + block_id: Some(block_id.to_string()), + table_id: Some(table_id.clone()), + cell_id: Some(cell_id.clone()), + ..SearchUnitRefs::default() + }, + )?; + } + if let Some(nested_tables) = child_map(&cell, "nested_tables_by_id") { + for nested_id in ordered_ids(&cell, "nested_table_ids") { + if let Some(nested) = child_map(&nested_tables, &nested_id) { + self.push_table_units(flows, block_id, Some(&cell_id), &nested)?; + } + } + } + } + Ok(()) + } + + fn push_flow_text_unit(&mut self, flows: &LoroMap, unit_kind: &str, flow_id: &str, mut refs: SearchUnitRefs) -> io::Result<()> { + let Some(flow) = child_map(flows, flow_id) else { + return Ok(()); + }; + let Some(text) = child_text(&flow, FLOW_TEXT_KEY) else { + return Ok(()); + }; + let body = searchable_flow_text(&text); + refs.flow_id = Some(flow_id.to_string()); + refs.cursors = text_cursor_fields(&text); + self.push_text_unit(unit_kind, &body, refs); + Ok(()) + } + + fn push_text_unit(&mut self, unit_kind: &str, text: &str, refs: SearchUnitRefs) { + let body = normalized_search_text(text); + if body.is_empty() { + return; + } + let (unit_start_cursor, unit_end_cursor) = refs.cursors.unwrap_or_default(); + let (paragraph_start_cursor, paragraph_end_cursor) = refs.paragraph_cursors.unwrap_or_default(); + let heading = self.heading_path.last().cloned().unwrap_or_default(); + let unit_id = stable_search_unit_id(self.document_id, self.next_unit_ix, self.frontier, unit_kind, &body); + self.next_unit_ix += 1; + self.units.push(SearchUnitChunk { + frontier: self.frontier.to_vec(), + unit_id, + unit_kind: unit_kind.to_string(), + flow_id: refs.flow_id, + block_id: refs.block_id, + table_id: refs.table_id, + cell_id: refs.cell_id, + heading_path: self.heading_path.clone(), + heading, + body: body.clone(), + insert_text: body, + unit_start_cursor, + unit_end_cursor, + paragraph_start_cursor, + paragraph_end_cursor, + }); + } + + fn update_heading_path(&mut self, level: usize, heading: String) { + if self.heading_path.len() <= level { + self.heading_path.resize(level + 1, String::new()); + } else { + self.heading_path.truncate(level + 1); + } + self.heading_path[level] = heading; + } +} + +#[derive(Default)] +struct SearchUnitRefs { + flow_id: Option, + block_id: Option, + table_id: Option, + cell_id: Option, + cursors: Option<(Vec, Vec)>, + paragraph_cursors: Option<(Vec, Vec)>, +} + +impl SearchUnitRefs { + fn for_block(block_id: &str) -> Self { + Self { + block_id: Some(block_id.to_string()), + ..Self::default() + } + } +} + +fn body_paragraph_cursor_ranges(text: &LoroText) -> Vec { + let mut ranges = Vec::new(); + let mut rendered_blocks = 0_usize; + let mut start = 0_usize; + let mut has_text = false; + let mut seen_sentinel = false; + let mut unicode_pos = 0_usize; + + for item in text.to_delta() { + let loro::TextDelta::Insert { insert, .. } = item else { + continue; + }; + for ch in insert.chars() { + match ch { + '\n' => { + if seen_sentinel { + ranges.push(BodyParagraphRange { start, end: unicode_pos }); + rendered_blocks += 1; + } else { + seen_sentinel = true; + } + start = unicode_pos + 1; + has_text = false; + }, + OBJECT_REPLACEMENT => { + if has_text { + ranges.push(BodyParagraphRange { start, end: unicode_pos }); + rendered_blocks += 1; + has_text = false; + } + rendered_blocks += 1; + start = unicode_pos + 1; + }, + _ => has_text = true, + } + unicode_pos += 1; + } + } + + if has_text || rendered_blocks == 0 && seen_sentinel { + ranges.push(BodyParagraphRange { + start, + end: text.len_unicode(), + }); + } + ranges +} + +fn cursor_fields(body: &LoroText, range: BodyParagraphRange) -> (Vec, Vec) { + let start_cursor = body + .get_cursor(range.start, Side::Left) + .map(|cursor| cursor.encode()) + .unwrap_or_default(); + let end_cursor = body + .get_cursor(range.end, Side::Right) + .map(|cursor| cursor.encode()) + .unwrap_or_default(); + (start_cursor, end_cursor) +} + +fn text_cursor_fields(text: &LoroText) -> Option<(Vec, Vec)> { + let len = text.len_unicode(); + if len == 0 { + return None; + } + let start = usize::from(text.to_string().starts_with('\n') && len > 1); + Some(( + text + .get_cursor(start, Side::Left) + .map(|cursor| cursor.encode()) + .unwrap_or_default(), + text + .get_cursor(len, Side::Right) + .map(|cursor| cursor.encode()) + .unwrap_or_default(), + )) +} + +fn input_paragraph_text(paragraph: &InputParagraph) -> String { + paragraph.runs.iter().map(|run| run.text.as_str()).collect() +} + +fn normalized_search_text(text: &str) -> String { + text + .chars() + .filter(|ch| *ch != OBJECT_REPLACEMENT) + .collect::() + .trim() + .to_string() +} + +fn searchable_flow_text(text: &LoroText) -> String { + let text = text.to_string(); + text.strip_prefix('\n').unwrap_or(&text).replace(OBJECT_REPLACEMENT, " ") +} + +fn child_map(parent: &LoroMap, key: &str) -> Option { + parent.get(key).and_then(|value| match value { + ValueOrContainer::Container(container) => container.into_map().ok(), + ValueOrContainer::Value(_) => None, + }) +} + +fn child_text(parent: &LoroMap, key: &str) -> Option { + parent.get(key).and_then(|value| match value { + ValueOrContainer::Container(container) => container.into_text().ok(), + ValueOrContainer::Value(_) => None, + }) +} + +fn map_keys(map: &LoroMap) -> Vec { + let mut keys = map.keys().map(|key| key.to_string()).collect::>(); + keys.sort(); + keys +} + +fn map_string_opt(map: &LoroMap, key: &str) -> Option { + map.get(key).and_then(|value| match value { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + }) +} + +fn ordered_ids(map: &LoroMap, key: &str) -> Vec { + let Some(ValueOrContainer::Container(Container::MovableList(list))) = map.get(key) else { + return Vec::new(); + }; + (0..list.len()) + .filter_map(|ix| match list.get(ix) { + Some(ValueOrContainer::Value(LoroValue::String(value))) => Some(value.to_string()), + _ => None, + }) + .collect() +} + +fn paragraph_unit_kind(paragraph: &InputParagraph) -> &'static str { + if paragraph + .runs + .iter() + .any(|run| matches!(run.styles.semantic, RunSemanticStyle::Custom(1))) + { + return "cite"; + } + match paragraph.style { + ParagraphStyle::Custom(0) => "pocket", + ParagraphStyle::Custom(1) => "hat", + ParagraphStyle::Custom(2) => "block", + ParagraphStyle::Custom(3) => "tag", + ParagraphStyle::Custom(4) => "analytic", + ParagraphStyle::Custom(6) => "undertag", + ParagraphStyle::Normal | ParagraphStyle::Custom(_) => "paragraph", + } +} + +fn heading_level(theme: &DocumentTheme, style: ParagraphStyle) -> Option { + let ParagraphStyle::Custom(slot) = style else { + return None; + }; + theme + .custom_paragraph_styles + .get(&(slot & 0x7f)) + .and_then(|style| style.section_level) + .map(usize::from) +} + +fn stable_search_unit_id(document_id: u128, unit_ix: usize, frontier: &[u8], unit_kind: &str, body: &str) -> u128 { + let mut hasher = blake3::Hasher::new(); + hasher.update(&document_id.to_le_bytes()); + hasher.update(&(unit_ix as u64).to_le_bytes()); + hasher.update(frontier); + hasher.update(unit_kind.as_bytes()); + hasher.update(body.as_bytes()); + let digest = hasher.finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest.as_bytes()[..16]); + u128::from_le_bytes(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::loro_schema::new_loro_document; + + #[test] + fn body_paragraph_ranges_match_projection_object_boundaries() -> io::Result<()> { + let doc = new_loro_document("ranges").map_err(loro_test_error)?; + let body = crate::loro_schema::body_text(&doc); + body.insert(1, "before").map_err(loro_test_error)?; + body + .insert(body.len_unicode(), &OBJECT_REPLACEMENT.to_string()) + .map_err(loro_test_error)?; + body + .insert(body.len_unicode(), "after") + .map_err(loro_test_error)?; + + let ranges = body_paragraph_cursor_ranges(&body); + assert_eq!(ranges.len(), 2); + assert_eq!(ranges[0].start..ranges[0].end, 1..7); + assert_eq!(ranges[1].start..ranges[1].end, 8..13); + Ok(()) + } + + #[test] + fn searchable_flow_text_preserves_one_leading_sentinel_newline() -> io::Result<()> { + let doc = new_loro_document("text").map_err(loro_test_error)?; + let body = crate::loro_schema::body_text(&doc); + body.insert(1, "\n\nalpha").map_err(loro_test_error)?; + + assert_eq!(searchable_flow_text(&body), "\nalpha"); + Ok(()) + } + + fn loro_test_error(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) + } +} diff --git a/crates/flowstate-docx/Cargo.toml b/crates/flowstate-docx/Cargo.toml index 6179e6a5..a1fc430e 100644 --- a/crates/flowstate-docx/Cargo.toml +++ b/crates/flowstate-docx/Cargo.toml @@ -13,6 +13,7 @@ docxide-pdf = { workspace = true } flowstate-document = { workspace = true } gpui = { version = "0.2.2", default-features = false } hotpath = { workspace = true } +imagesize = { workspace = true } lopdf = { workspace = true } quick-xml = { workspace = true } rdocx = { workspace = true } diff --git a/crates/flowstate-docx/src/cleaner.rs b/crates/flowstate-docx/src/cleaner.rs index 9bbe1eaa..8d9238b8 100644 --- a/crates/flowstate-docx/src/cleaner.rs +++ b/crates/flowstate-docx/src/cleaner.rs @@ -328,7 +328,6 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { } impl DocxCleanStats { - #[hotpath::measure] const fn has_changes(self) -> bool { self.underline_values_normalized + self.highlight_values_normalized @@ -340,7 +339,6 @@ impl DocxCleanStats { > 0 } - #[hotpath::measure] const fn merge(&mut self, other: Self) { self.underline_values_normalized += other.underline_values_normalized; self.highlight_values_normalized += other.highlight_values_normalized; diff --git a/crates/flowstate-docx/src/exporter.rs b/crates/flowstate-docx/src/exporter.rs index 93839ecf..f1154120 100644 --- a/crates/flowstate-docx/src/exporter.rs +++ b/crates/flowstate-docx/src/exporter.rs @@ -9,12 +9,12 @@ use std::{ }; use docx_rs::Docx; -use flowstate_document::Document; +use flowstate_document::DocumentProjection; use self::{blocks::add_block, formatting::docx_fonts, package::write_recompressed_docx, styles::add_flowstate_styles}; #[hotpath::measure] -pub fn write_docx(path: impl AsRef, document: &Document) -> io::Result<()> { +pub fn write_docx(path: impl AsRef, document: &DocumentProjection) -> io::Result<()> { let path = path.as_ref(); if let Some(parent) = path .parent() diff --git a/crates/flowstate-docx/src/exporter/blocks.rs b/crates/flowstate-docx/src/exporter/blocks.rs index ec2698eb..d080a9de 100644 --- a/crates/flowstate-docx/src/exporter/blocks.rs +++ b/crates/flowstate-docx/src/exporter/blocks.rs @@ -1,9 +1,9 @@ use docx_rs::{ - AlignmentType, BreakType, Docx, Paragraph as DocxParagraph, Run, Shading, Table as DocxTable, TableCell as DocxTableCell, + AlignmentType, BreakType, Docx, Paragraph as DocxParagraph, Pic, Run, Shading, Table as DocxTable, TableCell as DocxTableCell, TableRow as DocxTableRow, }; use flowstate_document::{ - Block, Document, DocumentTheme, EquationBlock, HighlightStyle, ImageBlock, Paragraph, ParagraphStyle, RunSemanticStyle, RunStyles, + Block, DocumentProjection, DocumentTheme, EquationBlock, HighlightStyle, ImageBlock, Paragraph, ParagraphStyle, RunSemanticStyle, RunStyles, SOFT_LINE_BREAK, TableBlock, TableCellBlock, TableCellParagraph, document_text_slice, }; @@ -13,17 +13,17 @@ use super::{ }; #[hotpath::measure] -pub(super) fn add_block(docx: Docx, document: &Document, block: &Block, theme: &DocumentTheme) -> Docx { +pub(super) fn add_block(docx: Docx, document: &DocumentProjection, block: &Block, theme: &DocumentTheme) -> Docx { match block { Block::Paragraph(paragraph) => docx.add_paragraph(export_document_paragraph(document, paragraph, theme)), Block::Table(table) => docx.add_table(export_table(table, theme)), - Block::Image(image) => docx.add_paragraph(placeholder_paragraph_for_image(document, image, theme)), + Block::Image(image) => docx.add_paragraph(export_image(document, image, theme)), Block::Equation(equation) => docx.add_paragraph(placeholder_paragraph_for_equation(equation, theme)), } } #[hotpath::measure] -fn export_document_paragraph(document: &Document, paragraph: &Paragraph, theme: &DocumentTheme) -> DocxParagraph { +fn export_document_paragraph(document: &DocumentProjection, paragraph: &Paragraph, theme: &DocumentTheme) -> DocxParagraph { let text = document_text_slice(document, paragraph.byte_range.clone()); export_paragraph_with_text(paragraph, &text, theme) } @@ -149,7 +149,23 @@ fn export_table(table: &TableBlock, theme: &DocumentTheme) -> DocxTable { } #[hotpath::measure] -fn placeholder_paragraph_for_image(document: &Document, image: &ImageBlock, theme: &DocumentTheme) -> DocxParagraph { +fn export_image(document: &DocumentProjection, image: &ImageBlock, theme: &DocumentTheme) -> DocxParagraph { + if let Some(asset) = document.assets.assets.get(&image.asset_id) + && !asset.is_loading_placeholder() + && asset_is_embeddable_image(asset.bytes.as_ref()) + { + let (width_px, height_px) = image_dimensions(asset.bytes.as_ref(), image); + let paragraph = match image.alignment { + flowstate_document::BlockAlignment::Left => DocxParagraph::new(), + flowstate_document::BlockAlignment::Center => DocxParagraph::new().align(AlignmentType::Center), + flowstate_document::BlockAlignment::Right => DocxParagraph::new().align(AlignmentType::Right), + }; + return paragraph.add_run( + Run::new() + .fonts(docx_fonts(theme)) + .add_image(Pic::new_with_dimensions(asset.bytes.as_ref().clone(), width_px, height_px)), + ); + } let mut text = image.alt_text.to_string(); if text.trim().is_empty() && let Some(asset) = document.assets.assets.get(&image.asset_id) @@ -168,6 +184,24 @@ fn placeholder_paragraph_for_image(document: &Document, image: &ImageBlock, them ) } +/// OOXML natively supports PNG and JPEG image parts (see docx content-type +/// defaults), so both are embedded directly; other formats fall back to an +/// alt-text run. +fn asset_is_embeddable_image(bytes: &[u8]) -> bool { + let is_png = bytes.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]); + let is_jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]); + is_png || is_jpeg +} + +fn image_dimensions(bytes: &[u8], image: &ImageBlock) -> (u32, u32) { + if let flowstate_document::ImageSizing::Fixed { width_px, height_px } = image.sizing { + return (width_px.max(1), height_px.unwrap_or(width_px).max(1)); + } + imagesize::blob_size(bytes) + .map(|size| (size.width.max(1) as u32, size.height.max(1) as u32)) + .unwrap_or((640, 480)) +} + #[hotpath::measure] fn placeholder_paragraph_for_equation(equation: &EquationBlock, theme: &DocumentTheme) -> DocxParagraph { DocxParagraph::new().align(AlignmentType::Center).add_run( diff --git a/crates/flowstate-docx/src/interpreter.rs b/crates/flowstate-docx/src/interpreter.rs index 12d978b1..b3d798ea 100644 --- a/crates/flowstate-docx/src/interpreter.rs +++ b/crates/flowstate-docx/src/interpreter.rs @@ -1,3 +1,6 @@ +mod omml; +mod structured; + use std::{io, path::Path}; use quick_xml::{ @@ -13,7 +16,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; use super::cleaner::{CleanedDocx, DocxCleanReport, clean_docx_path}; use flowstate_document::{ - Document, DocumentParagraphInput, DocumentRunInput, DocumentTheme, ParagraphStyle, RunSemanticStyle, RunStyles, document_from_paragraphs, + DocumentParagraphInput, DocumentProjection, DocumentRunInput, ImportedLoroDocument, ParagraphStyle, RunSemanticStyle, RunStyles, + document_from_input_blocks, document_from_paragraphs, flowstate_document_theme, import_document_projection, }; pub const RECOGNITION_RULES: &[RecognitionRule] = &[ @@ -83,35 +87,91 @@ pub struct DocxConversionReport { pub recognition_rules: &'static [RecognitionRule], pub paragraphs_imported: usize, pub runs_imported: usize, + pub tables_imported: usize, + pub images_imported: usize, + pub equations_imported: usize, pub unknown_paragraph_styles: Vec, pub unknown_run_styles: Vec, } #[hotpath::measure] -pub fn convert_docx_to_document(path: impl AsRef) -> io::Result<(Document, DocxConversionReport)> { +pub fn convert_docx_to_document(path: impl AsRef) -> io::Result<(DocumentProjection, DocxConversionReport)> { let cleaned = clean_docx_path(path)?; convert_cleaned_docx_to_document(cleaned) } #[hotpath::measure] -pub fn convert_docx_bytes_to_document(bytes: &[u8]) -> io::Result<(Document, DocxConversionReport)> { +pub fn convert_docx_bytes_to_document(bytes: &[u8]) -> io::Result<(DocumentProjection, DocxConversionReport)> { let cleaned = super::cleaner::clean_docx_bytes(bytes)?; convert_cleaned_docx_to_document(cleaned) } #[hotpath::measure] -pub fn convert_cleaned_docx_to_document(cleaned: CleanedDocx) -> io::Result<(Document, DocxConversionReport)> { - let CleanedDocx { - bytes, - main_document_xml, - report: clean_report, - } = cleaned; - let docx = RDocxDocument::from_bytes(&bytes).map_err(rdocx_error)?; - let direct_properties = match main_document_xml.as_deref() { +pub fn convert_cleaned_docx_to_document(cleaned: CleanedDocx) -> io::Result<(DocumentProjection, DocxConversionReport)> { + let interpreted = interpret_cleaned_docx(&cleaned)?; + build_structured_document(&cleaned, interpreted) +} + +/// Walks the cleaned DOCX body into the structured block model (paragraphs, +/// tables, images, equations) and folds the object counts into the report. When +/// the body has no tables/images/equations the structured walk is discarded and +/// the flat paragraph projection is used, preserving the prior behavior exactly. +#[hotpath::measure] +fn build_structured_document(cleaned: &CleanedDocx, interpreted: InterpretedDocx) -> io::Result<(DocumentProjection, DocxConversionReport)> { + let structured = structured::interpret_structured(cleaned, &interpreted.paragraphs)?; + let mut report = interpreted.report; + report.tables_imported = structured.tables_imported; + report.images_imported = structured.images_imported; + report.equations_imported = structured.equations_imported; + + let has_objects = structured.tables_imported + structured.images_imported + structured.equations_imported > 0; + let document = if has_objects { + let mut document = document_from_input_blocks(flowstate_document_theme(), structured.blocks); + for (asset_id, record) in structured.assets { + document.assets.assets.insert(asset_id, record); + } + document + } else { + document_from_paragraphs(flowstate_document_theme(), interpreted.paragraphs) + }; + Ok((document, report)) +} + +/// Imports DOCX semantics directly into the canonical Loro document and returns +/// the frontier-matched initial projection. No package, snapshot, search cache, +/// or second Loro projection is created on the open path. +#[hotpath::measure] +pub fn import_docx_to_loro(path: impl AsRef, title: &str) -> io::Result<(ImportedLoroDocument, DocxConversionReport)> { + let cleaned = clean_docx_path(path)?; + import_cleaned_docx_to_loro(cleaned, title) +} + +#[hotpath::measure] +pub fn import_docx_bytes_to_loro(bytes: &[u8], title: &str) -> io::Result<(ImportedLoroDocument, DocxConversionReport)> { + let cleaned = super::cleaner::clean_docx_bytes(bytes)?; + import_cleaned_docx_to_loro(cleaned, title) +} + +#[hotpath::measure] +pub fn import_cleaned_docx_to_loro(cleaned: CleanedDocx, title: &str) -> io::Result<(ImportedLoroDocument, DocxConversionReport)> { + let interpreted = interpret_cleaned_docx(&cleaned)?; + let (document, report) = build_structured_document(&cleaned, interpreted)?; + let imported = import_document_projection(document, title)?; + Ok((imported, report)) +} + +struct InterpretedDocx { + paragraphs: Vec, + report: DocxConversionReport, +} + +#[hotpath::measure] +fn interpret_cleaned_docx(cleaned: &CleanedDocx) -> io::Result { + let docx = RDocxDocument::from_bytes(&cleaned.bytes).map_err(rdocx_error)?; + let direct_properties = match cleaned.main_document_xml.as_deref() { Some(doc_xml) => direct_properties_by_paragraph_xml(doc_xml)?, - None => direct_properties_by_paragraph_package(&bytes)?, + None => direct_properties_by_paragraph_package(&cleaned.bytes)?, }; - drop(main_document_xml); let style_resolver = StyleResolver::new(&docx); let docx_paragraphs = docx.paragraphs(); let mut paragraphs = Vec::with_capacity(docx_paragraphs.len()); @@ -272,17 +332,18 @@ pub fn convert_cleaned_docx_to_document(cleaned: CleanedDocx) -> io::Result<(Doc paragraphs.push(DocumentParagraphInput { style, runs }); } - let paragraphs_imported = paragraphs.len(); - let document = document_from_paragraphs(DocumentTheme::default(), paragraphs); let report = DocxConversionReport { - clean: clean_report, + clean: cleaned.report.clone(), recognition_rules: RECOGNITION_RULES, - paragraphs_imported, + paragraphs_imported: paragraphs.len(), runs_imported, + tables_imported: 0, + images_imported: 0, + equations_imported: 0, unknown_paragraph_styles, unknown_run_styles, }; - Ok((document, report)) + Ok(InterpretedDocx { paragraphs, report }) } #[derive(Clone, Debug)] struct RunFact { diff --git a/crates/flowstate-docx/src/interpreter/omml.rs b/crates/flowstate-docx/src/interpreter/omml.rs new file mode 100644 index 00000000..cbaed7c0 --- /dev/null +++ b/crates/flowstate-docx/src/interpreter/omml.rs @@ -0,0 +1,478 @@ +//! Minimal Office `MathML` (OMML, `m:oMath`) → `LaTeX` conversion for DOCX import. +//! +//! Word stores equations as OMML, while the Flowstate equation model stores a +//! single LaTeX source string ([`InputEquationSyntax::Latex`] is the only syntax +//! the equation block exposes). No available crate converts OMML → LaTeX (the +//! `mitex` crate converts LaTeX → Typst), so this module implements a focused, +//! best-effort converter over the common OMML constructs (runs/text, fractions, +//! sub/superscripts, radicals, delimiters, n-ary operators, functions, accents, +//! overbars, limits and matrices). Unrecognized constructs degrade to their +//! concatenated literal text so that symbols are preserved rather than dropped. + +use flowstate_document::{InputEquationBlock, InputEquationDisplay, InputEquationSyntax}; +use quick_xml::{ + Reader as XmlReader, + events::{BytesStart, Event}, +}; + +/// Parse a captured OMML container (`m:oMath`, `m:oMathPara`, or any element that +/// wraps them) and return one [`InputEquationBlock`] per `m:oMath`, in document +/// order. `m:oMath` inside an `m:oMathPara` becomes a display equation; a bare +/// `m:oMath` becomes an inline-like equation. +#[hotpath::measure] +pub(super) fn equations_from_container_bytes(bytes: &[u8]) -> Vec { + let Some(root) = parse_tree(bytes) else { + return Vec::new(); + }; + let mut equations = Vec::new(); + collect_equations(&root, false, &mut equations); + equations +} + +/// Returns `true` when the captured bytes look like they contain Office Math, so +/// callers can cheaply skip non-math captured XML (bookmarks, SDTs, ...). +pub(super) fn contains_office_math(bytes: &[u8]) -> bool { + memmem(bytes, b"oMath") +} + +fn collect_equations(node: &Node, in_math_para: bool, out: &mut Vec) { + if node.local == "oMath" { + let source = latex_from_children(node).trim().to_string(); + if !source.is_empty() { + out.push(InputEquationBlock { + source, + syntax: InputEquationSyntax::Latex, + display: if in_math_para { + InputEquationDisplay::Display + } else { + InputEquationDisplay::InlineLikeParagraph + }, + }); + } + return; + } + let nested = in_math_para || node.local == "oMathPara"; + for child in &node.children { + collect_equations(child, nested, out); + } +} + +struct Node { + local: String, + val: Option, + text: String, + children: Vec, +} + +fn parse_tree(bytes: &[u8]) -> Option { + let mut reader = XmlReader::from_reader(bytes); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut root: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(event)) => stack.push(node_from_start(&event)), + Ok(Event::Empty(event)) => { + let node = node_from_start(&event); + if let Some(parent) = stack.last_mut() { + parent.children.push(node); + } else { + root.get_or_insert(node); + } + }, + Ok(Event::Text(event)) => { + if let Some(top) = stack.last_mut() + && let Ok(text) = event.unescape() + { + top.text.push_str(&text); + } + }, + Ok(Event::End(_)) => { + if let Some(node) = stack.pop() { + if let Some(parent) = stack.last_mut() { + parent.children.push(node); + } else { + root.get_or_insert(node); + } + } + }, + Ok(Event::Eof) => break, + Err(_) => return None, + _ => {}, + } + buf.clear(); + } + + root +} + +fn node_from_start(event: &BytesStart<'_>) -> Node { + let local = local_name(event.name().as_ref()).to_owned(); + let mut val = None; + for attr in event.attributes().flatten() { + if local_name(attr.key.as_ref()) == "val" { + val = std::str::from_utf8(attr.value.as_ref()) + .ok() + .map(|value| value.to_owned()); + } + } + Node { + local, + val, + text: String::new(), + children: Vec::new(), + } +} + +fn local_name(name: &[u8]) -> &str { + let name = std::str::from_utf8(name).unwrap_or_default(); + name.rsplit(':').next().unwrap_or(name) +} + +fn child<'node>(node: &'node Node, local: &str) -> Option<&'node Node> { + node.children.iter().find(|child| child.local == local) +} + +fn latex_from_children(node: &Node) -> String { + let mut out = String::new(); + for child in &node.children { + out.push_str(&latex_from_node(child)); + } + out +} + +fn latex_from_arg(node: &Node, local: &str) -> String { + child(node, local) + .map(latex_from_children) + .unwrap_or_default() +} + +fn latex_from_node(node: &Node) -> String { + match node.local.as_str() { + "t" => map_text(&node.text), + "r" => latex_from_children(node), + "f" => format!("\\frac{{{}}}{{{}}}", latex_from_arg(node, "num"), latex_from_arg(node, "den")), + "sSup" => format!("{}^{{{}}}", braced(&latex_from_arg(node, "e")), latex_from_arg(node, "sup")), + "sSub" => format!("{}_{{{}}}", braced(&latex_from_arg(node, "e")), latex_from_arg(node, "sub")), + "sSubSup" | "sPre" => format!( + "{}_{{{}}}^{{{}}}", + braced(&latex_from_arg(node, "e")), + latex_from_arg(node, "sub"), + latex_from_arg(node, "sup") + ), + "rad" => { + let inner = latex_from_arg(node, "e"); + let degree = latex_from_arg(node, "deg"); + if degree.is_empty() { + format!("\\sqrt{{{inner}}}") + } else { + format!("\\sqrt[{degree}]{{{inner}}}") + } + }, + "d" => { + let (open, close) = delimiter_chars(node); + let inner = node + .children + .iter() + .filter(|child| child.local == "e") + .map(latex_from_children) + .collect::>() + .join(","); + format!("\\left{open}{inner}\\right{close}") + }, + "nary" => latex_nary(node), + "func" => { + let name = latex_from_arg(node, "fName"); + let argument = latex_from_arg(node, "e"); + if argument.is_empty() { name } else { format!("{name} {argument}") } + }, + "acc" => latex_accent(node), + "bar" => format!("\\overline{{{}}}", latex_from_arg(node, "e")), + "limLow" => format!("{}_{{{}}}", braced(&latex_from_arg(node, "e")), latex_from_arg(node, "lim")), + "limUpp" => format!("{}^{{{}}}", braced(&latex_from_arg(node, "e")), latex_from_arg(node, "lim")), + "m" => latex_matrix(node), + "box" | "borderBox" | "groupChr" | "eqArr" | "e" | "num" | "den" | "oMath" => latex_from_children(node), + // Property containers carry presentation hints only, never rendered content. + name if name.ends_with("Pr") => String::new(), + _ => latex_from_children(node), + } +} + +fn braced(value: &str) -> String { + if value.chars().count() == 1 { + value.to_owned() + } else { + format!("{{{value}}}") + } +} + +fn delimiter_chars(node: &Node) -> (String, String) { + let properties = child(node, "dPr"); + let beginning = properties + .and_then(|properties| child(properties, "begChr")) + .and_then(|chr| chr.val.clone()); + let ending = properties + .and_then(|properties| child(properties, "endChr")) + .and_then(|chr| chr.val.clone()); + (latex_delimiter(beginning.as_deref(), "("), latex_delimiter(ending.as_deref(), ")")) +} + +fn latex_delimiter(chr: Option<&str>, default: &str) -> String { + match chr { + None => default.to_owned(), + Some("") => ".".to_owned(), + Some("{") => "\\{".to_owned(), + Some("}") => "\\}".to_owned(), + Some("‖") => "\\|".to_owned(), + Some("⟨") => "\\langle".to_owned(), + Some("⟩") => "\\rangle".to_owned(), + Some("⌊") => "\\lfloor".to_owned(), + Some("⌋") => "\\rfloor".to_owned(), + Some("⌈") => "\\lceil".to_owned(), + Some("⌉") => "\\rceil".to_owned(), + Some(other) => other.to_owned(), + } +} + +fn latex_nary(node: &Node) -> String { + let chr = child(node, "naryPr") + .and_then(|properties| child(properties, "chr")) + .and_then(|chr| chr.val.clone()); + let mut out = nary_operator(chr.as_deref()); + let sub = latex_from_arg(node, "sub"); + if !sub.is_empty() { + out.push_str("_{"); + out.push_str(&sub); + out.push('}'); + } + let sup = latex_from_arg(node, "sup"); + if !sup.is_empty() { + out.push_str("^{"); + out.push_str(&sup); + out.push('}'); + } + let operand = latex_from_arg(node, "e"); + if !operand.is_empty() { + out.push(' '); + out.push_str(&operand); + } + out +} + +fn nary_operator(chr: Option<&str>) -> String { + match chr { + None | Some("∑") => "\\sum".to_owned(), + Some("∏") => "\\prod".to_owned(), + Some("∐") => "\\coprod".to_owned(), + Some("∫") => "\\int".to_owned(), + Some("∬") => "\\iint".to_owned(), + Some("∭") => "\\iiint".to_owned(), + Some("∮") => "\\oint".to_owned(), + Some("⋃") => "\\bigcup".to_owned(), + Some("⋂") => "\\bigcap".to_owned(), + Some("⋁") => "\\bigvee".to_owned(), + Some("⋀") => "\\bigwedge".to_owned(), + Some(other) => other.to_owned(), + } +} + +fn latex_accent(node: &Node) -> String { + let chr = child(node, "accPr") + .and_then(|properties| child(properties, "chr")) + .and_then(|chr| chr.val.clone()); + let command = match chr.as_deref() { + Some("\u{0303}" | "~") => "\\tilde", + Some("\u{0304}" | "¯") => "\\bar", + Some("\u{0307}") => "\\dot", + Some("\u{0308}") => "\\ddot", + Some("\u{20D7}" | "→") => "\\vec", + Some("\u{0306}") => "\\breve", + Some("\u{030C}") => "\\check", + Some("\u{0301}") => "\\acute", + Some("\u{0300}") => "\\grave", + _ => "\\hat", + }; + format!("{command}{{{}}}", latex_from_arg(node, "e")) +} + +fn latex_matrix(node: &Node) -> String { + let rows = node + .children + .iter() + .filter(|child| child.local == "mr") + .map(|row| { + row + .children + .iter() + .filter(|child| child.local == "e") + .map(latex_from_children) + .collect::>() + .join(" & ") + }) + .collect::>() + .join(" \\\\ "); + format!("\\begin{{matrix}}{rows}\\end{{matrix}}") +} + +fn map_text(text: &str) -> String { + let mut out = String::new(); + for ch in text.chars() { + if let Some(mapped) = map_symbol(ch) { + out.push_str(mapped); + } else { + push_escaped(&mut out, ch); + } + } + out +} + +fn push_escaped(out: &mut String, ch: char) { + match ch { + '%' | '$' | '#' | '&' | '_' | '{' | '}' => { + out.push('\\'); + out.push(ch); + }, + '\\' => out.push_str("\\backslash "), + _ => out.push(ch), + } +} + +#[allow(clippy::too_many_lines, reason = "A single flat symbol table is the clearest mapping form.")] +fn map_symbol(ch: char) -> Option<&'static str> { + Some(match ch { + 'α' => "\\alpha ", + 'β' => "\\beta ", + 'γ' => "\\gamma ", + 'δ' => "\\delta ", + 'ε' => "\\epsilon ", + 'ζ' => "\\zeta ", + 'η' => "\\eta ", + 'θ' => "\\theta ", + 'ι' => "\\iota ", + 'κ' => "\\kappa ", + 'λ' => "\\lambda ", + 'μ' => "\\mu ", + 'ν' => "\\nu ", + 'ξ' => "\\xi ", + 'π' => "\\pi ", + 'ρ' => "\\rho ", + 'σ' => "\\sigma ", + 'τ' => "\\tau ", + 'υ' => "\\upsilon ", + 'φ' => "\\phi ", + 'χ' => "\\chi ", + 'ψ' => "\\psi ", + 'ω' => "\\omega ", + 'Γ' => "\\Gamma ", + 'Δ' => "\\Delta ", + 'Θ' => "\\Theta ", + 'Λ' => "\\Lambda ", + 'Ξ' => "\\Xi ", + 'Π' => "\\Pi ", + 'Σ' => "\\Sigma ", + 'Φ' => "\\Phi ", + 'Ψ' => "\\Psi ", + 'Ω' => "\\Omega ", + '×' => "\\times ", + '÷' => "\\div ", + '±' => "\\pm ", + '∓' => "\\mp ", + '⋅' => "\\cdot ", + '∗' => "\\ast ", + '≤' => "\\le ", + '≥' => "\\ge ", + '≠' => "\\ne ", + '≈' => "\\approx ", + '≡' => "\\equiv ", + '∝' => "\\propto ", + '→' => "\\to ", + '←' => "\\leftarrow ", + '⇒' => "\\Rightarrow ", + '⇔' => "\\Leftrightarrow ", + '↔' => "\\leftrightarrow ", + '∞' => "\\infty ", + '∂' => "\\partial ", + '∇' => "\\nabla ", + '∈' => "\\in ", + '∉' => "\\notin ", + '⊂' => "\\subset ", + '⊆' => "\\subseteq ", + '⊃' => "\\supset ", + '⊇' => "\\supseteq ", + '∪' => "\\cup ", + '∩' => "\\cap ", + '∀' => "\\forall ", + '∃' => "\\exists ", + '∅' => "\\emptyset ", + '∧' => "\\wedge ", + '∨' => "\\vee ", + '¬' => "\\neg ", + '∑' => "\\sum ", + '∏' => "\\prod ", + '∫' => "\\int ", + '√' => "\\surd ", + '°' => "^{\\circ}", + '′' => "'", + '″' => "''", + _ => return None, + }) +} + +fn memmem(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sources(bytes: &str) -> Vec { + equations_from_container_bytes(bytes.as_bytes()) + .into_iter() + .map(|equation| equation.source) + .collect() + } + + #[test] + fn inline_fraction_converts_to_latex() { + let omml = r"12"; + let equations = equations_from_container_bytes(omml.as_bytes()); + assert_eq!(equations.len(), 1); + assert_eq!(equations[0].source, "\\frac{1}{2}"); + assert_eq!(equations[0].display, InputEquationDisplay::InlineLikeParagraph); + } + + #[test] + fn math_para_marks_display_and_handles_superscript() { + let omml = + r"x2"; + let equations = equations_from_container_bytes(omml.as_bytes()); + assert_eq!(equations.len(), 1); + assert_eq!(equations[0].source, "x^{2}"); + assert_eq!(equations[0].display, InputEquationDisplay::Display); + } + + #[test] + fn nary_sum_uses_limits_and_operand() { + let omml = r#"i=1ni"#; + assert_eq!(sources(omml), vec!["\\sum_{i=1}^{n} i".to_string()]); + } + + #[test] + fn radical_and_symbols_are_mapped() { + let omml = r"α≤β"; + assert_eq!(sources(omml), vec!["\\sqrt{\\alpha \\le \\beta }".to_string()]); + } + + #[test] + fn non_math_xml_yields_no_equations() { + let bookmark = r#""#; + assert!(equations_from_container_bytes(bookmark.as_bytes()).is_empty()); + assert!(!contains_office_math(bookmark.as_bytes())); + } +} diff --git a/crates/flowstate-docx/src/interpreter/structured.rs b/crates/flowstate-docx/src/interpreter/structured.rs new file mode 100644 index 00000000..b9b4c6fe --- /dev/null +++ b/crates/flowstate-docx/src/interpreter/structured.rs @@ -0,0 +1,693 @@ +//! Structured DOCX body import (§26): walks `w:body` in document order and emits +//! the full block model — paragraphs, tables, images and equations — rather than +//! the flat paragraph stream produced by [`super::interpret_cleaned_docx`]. +//! +//! Paragraph recognition is *not* duplicated here. `docx.paragraphs()` (and the +//! pre-computed [`DocumentParagraphInput`] slice it backs) enumerates exactly the +//! top-level `w:p` children of `w:body` in order — table-cell paragraphs are +//! filtered out by `rdocx` (`CT_Body::paragraphs` only keeps `BodyContent::Paragraph`). +//! The walk therefore consumes one slice entry per top-level `w:p` it encounters, +//! keeping the rich paragraph/run heuristics intact, and recognizes table-cell +//! paragraphs through a deliberately small independent path (plain runs + Normal +//! style). +//! +//! Ordering choice: a top-level paragraph that also carries inline drawings or +//! Office Math is emitted as the paragraph block first, then one image block per +//! drawing, then one equation block per `m:oMath`. Empty paragraphs that exist +//! only to host an object are still emitted (kept simple to preserve cursor +//! alignment) and render as a blank line next to the object. + +use std::{io, io::Cursor, sync::Arc}; + +use quick_xml::{ + Reader as XmlReader, + events::{BytesStart, Event}, +}; +use rdocx_opc::OpcPackage; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::cleaner::CleanedDocx; +use flowstate_document::{ + AssetId, AssetRecord, DocumentParagraphInput, InputBlock, InputBlockAlignment, InputImageBlock, InputImageSizing, InputParagraph, InputRun, + InputTableBlock, InputTableCell, InputTableCellBlock, InputTableColumnWidth, InputTableRow, InputTableStyle, ParagraphStyle, RunStyles, +}; + +use super::omml; + +/// Structured import result. Blocks are in body order; `assets` are the deduped +/// image assets keyed by a content-stable [`AssetId`] for insertion into the +/// projection's asset store. +#[derive(Default)] +pub(super) struct StructuredDocx { + pub(super) blocks: Vec, + pub(super) assets: Vec<(AssetId, AssetRecord)>, + pub(super) tables_imported: usize, + pub(super) images_imported: usize, + pub(super) equations_imported: usize, +} + +#[hotpath::measure] +pub(super) fn interpret_structured(cleaned: &CleanedDocx, paragraphs: &[DocumentParagraphInput]) -> io::Result { + // The package carries the main-document relationships and media parts needed + // to resolve embedded images; a missing/unreadable package simply yields no + // image assets rather than failing the import. + let package = OpcPackage::from_reader(Cursor::new(cleaned.bytes.as_slice())).ok(); + let main_part = package.as_ref().and_then(OpcPackage::main_document_part); + + let owned_xml; + let doc_xml: &[u8] = match cleaned.main_document_xml.as_deref() { + Some(xml) => xml, + None => { + let Some((package, main_part)) = package.as_ref().zip(main_part.as_deref()) else { + return Ok(StructuredDocx::default()); + }; + let Some(part) = package.get_part(main_part) else { + return Ok(StructuredDocx::default()); + }; + owned_xml = part.to_vec(); + &owned_xml + }, + }; + + let Some(root) = parse_tree(doc_xml) else { + return Ok(StructuredDocx::default()); + }; + let Some(body) = child(&root, "body") else { + return Ok(StructuredDocx::default()); + }; + + let mut walker = StructuredWalker { + doc_xml, + paragraphs, + package: package.as_ref(), + main_part: main_part.as_deref(), + cursor: 0, + blocks: Vec::new(), + assets: Vec::new(), + emitted_assets: FxHashSet::default(), + tables_imported: 0, + images_imported: 0, + equations_imported: 0, + }; + walker.walk_body(body); + + Ok(StructuredDocx { + blocks: walker.blocks, + assets: walker.assets, + tables_imported: walker.tables_imported, + images_imported: walker.images_imported, + equations_imported: walker.equations_imported, + }) +} + +struct StructuredWalker<'ctx> { + doc_xml: &'ctx [u8], + paragraphs: &'ctx [DocumentParagraphInput], + package: Option<&'ctx OpcPackage>, + main_part: Option<&'ctx str>, + cursor: usize, + blocks: Vec, + assets: Vec<(AssetId, AssetRecord)>, + emitted_assets: FxHashSet, + tables_imported: usize, + images_imported: usize, + equations_imported: usize, +} + +impl StructuredWalker<'_> { + fn walk_body(&mut self, body: &XmlNode) { + for node in &body.children { + match node.local.as_str() { + "p" => self.walk_paragraph(node), + "tbl" => { + let table = self.parse_table(node); + self.blocks.push(InputBlock::Table(table)); + }, + _ => {}, + } + } + } + + fn walk_paragraph(&mut self, paragraph_node: &XmlNode) { + // Top-level paragraphs are 1:1 with the pre-computed slice; the cursor only + // advances on `w:p`, so tables, section properties and SDTs never desync it. + let paragraph = if self.cursor < self.paragraphs.len() { + let input = input_paragraph_from_document(&self.paragraphs[self.cursor]); + self.cursor += 1; + input + } else { + cell_paragraph(paragraph_node) + }; + self.blocks.push(InputBlock::Paragraph(paragraph)); + + let mut drawings = Vec::new(); + collect_descendants(paragraph_node, "drawing", &mut drawings); + for drawing in drawings { + if let Some(image) = self.image_from_drawing(drawing) { + self.blocks.push(InputBlock::Image(image)); + self.images_imported += 1; + } + } + + if let Some(bytes) = self.doc_xml.get(paragraph_node.start..paragraph_node.end) + && omml::contains_office_math(bytes) + { + for equation in omml::equations_from_container_bytes(bytes) { + self.blocks.push(InputBlock::Equation(equation)); + self.equations_imported += 1; + } + } + } + + fn parse_table(&mut self, node: &XmlNode) -> InputTableBlock { + self.tables_imported += 1; + let column_widths = table_column_widths(node); + let mut rows: Vec = Vec::new(); + let mut header_row = false; + let mut first_row = true; + // grid column -> (row index, cell index) of the cell that started a vertical + // merge, so continuation cells fold into its `row_span` instead of emitting. + let mut vertical_open: FxHashMap = FxHashMap::default(); + + for row_node in &node.children { + if row_node.local != "tr" { + continue; + } + if first_row { + first_row = false; + header_row = row_is_header(row_node); + } + let mut cells: Vec = Vec::new(); + let mut grid_col = 0_usize; + for cell_node in &row_node.children { + if cell_node.local == "tc" { + grid_col = self.add_table_cell(cell_node, grid_col, &mut rows, &mut cells, &mut vertical_open); + } + } + rows.push(InputTableRow { cells }); + } + + InputTableBlock { + rows, + column_widths, + style: InputTableStyle { header_row }, + } + } + + /// Appends one `w:tc` to the current row (or folds a vertical-merge + /// continuation into the originating cell above) and returns the next grid + /// column. `rows` holds the already-completed rows; `cells` is the row in + /// progress. + fn add_table_cell( + &mut self, + cell_node: &XmlNode, + grid_col: usize, + rows: &mut [InputTableRow], + cells: &mut Vec, + vertical_open: &mut FxHashMap, + ) -> usize { + let col_span = cell_grid_span(cell_node); + match cell_vertical_merge(cell_node) { + VerticalMerge::Continue => { + if let Some(&(row_ix, cell_ix)) = vertical_open.get(&grid_col) + && let Some(row) = rows.get_mut(row_ix) + && let Some(cell) = row.cells.get_mut(cell_ix) + { + cell.row_span = cell.row_span.saturating_add(1); + } + }, + VerticalMerge::Restart => { + vertical_open.insert(grid_col, (rows.len(), cells.len())); + let blocks = self.cell_blocks(cell_node); + cells.push(InputTableCell { + blocks, + row_span: 1, + col_span, + }); + }, + VerticalMerge::None => { + vertical_open.remove(&grid_col); + let blocks = self.cell_blocks(cell_node); + cells.push(InputTableCell { + blocks, + row_span: 1, + col_span, + }); + }, + } + grid_col + usize::from(col_span.max(1)) + } + + fn cell_blocks(&mut self, cell_node: &XmlNode) -> Vec { + let mut blocks = Vec::new(); + for node in &cell_node.children { + match node.local.as_str() { + "p" => blocks.push(InputTableCellBlock::Paragraph(cell_paragraph(node))), + "tbl" => blocks.push(InputTableCellBlock::Table(self.parse_table(node))), + _ => {}, + } + } + if blocks.is_empty() { + blocks.push(InputTableCellBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Normal, + runs: Vec::new(), + })); + } + blocks + } + + fn image_from_drawing(&mut self, drawing: &XmlNode) -> Option { + let relationship_id = find_descendant_with_attr(drawing, "blip", "embed")?.attr("embed")?; + let asset_id = self.resolve_image(relationship_id)?; + let alt_text = find_descendant(drawing, "docPr") + .and_then(|doc_pr| doc_pr.attr("descr").or_else(|| doc_pr.attr("name"))) + .unwrap_or_default() + .to_owned(); + Some(InputImageBlock { + asset_id, + alt_text, + caption: None, + sizing: drawing_sizing(drawing), + alignment: InputBlockAlignment::Left, + }) + } + + fn resolve_image(&mut self, relationship_id: &str) -> Option { + let package = self.package?; + let main_part = self.main_part?; + let relationship = package + .get_part_rels(main_part)? + .get_by_id(relationship_id)?; + let target = OpcPackage::resolve_rel_target(main_part, &relationship.target); + let bytes = package.get_part(&target)?; + let asset_id = asset_id_from_bytes(bytes); + if self.emitted_assets.insert(asset_id) { + self.assets.push(( + asset_id, + AssetRecord { + id: asset_id, + mime_type: mime_from_path(&target).into(), + original_name: Some(file_name(&target).into()), + content_hash: AssetRecord::stable_content_hash(bytes), + bytes: Arc::new(bytes.to_vec()), + }, + )); + } + Some(asset_id) + } +} + +/// Vertical-merge state for a table cell (`w:tcPr/w:vMerge`). +enum VerticalMerge { + None, + Restart, + Continue, +} + +fn input_paragraph_from_document(paragraph: &DocumentParagraphInput) -> InputParagraph { + InputParagraph { + style: paragraph.style, + runs: paragraph + .runs + .iter() + .map(|run| InputRun { + text: run.text.clone(), + styles: run.styles, + }) + .collect(), + } +} + +/// Lightweight cell-paragraph recognition: table-cell paragraphs are not part of +/// the recognized slice, so they are imported as Normal-styled plain text. This +/// trades run-level semantics inside tables for a small, robust path. +fn cell_paragraph(paragraph_node: &XmlNode) -> InputParagraph { + let mut text = String::new(); + collect_run_text(paragraph_node, &mut text); + let runs = if text.is_empty() { + Vec::new() + } else { + vec![InputRun { + text, + styles: RunStyles::default(), + }] + }; + InputParagraph { + style: ParagraphStyle::Normal, + runs, + } +} + +fn collect_run_text(node: &XmlNode, out: &mut String) { + for child_node in &node.children { + match child_node.local.as_str() { + "t" => out.push_str(&child_node.text), + "tab" => out.push('\t'), + "br" | "cr" => out.push('\n'), + _ => collect_run_text(child_node, out), + } + } +} + +fn table_column_widths(node: &XmlNode) -> Vec { + let Some(grid) = child(node, "tblGrid") else { + return Vec::new(); + }; + grid + .children + .iter() + .filter(|candidate| candidate.local == "gridCol") + .map(|column| { + column + .attr("w") + .and_then(|width| width.parse::().ok()) + .filter(|twips| *twips > 0) + .and_then(|twips| u32::try_from(twips.saturating_mul(96) / 1440).ok()) + .map_or(InputTableColumnWidth::Auto, InputTableColumnWidth::FixedPx) + }) + .collect() +} + +fn cell_grid_span(cell_node: &XmlNode) -> u16 { + child(cell_node, "tcPr") + .and_then(|properties| child(properties, "gridSpan")) + .and_then(|grid_span| grid_span.attr("val")) + .and_then(|value| value.parse::().ok()) + .filter(|span| *span >= 1) + .unwrap_or(1) +} + +fn cell_vertical_merge(cell_node: &XmlNode) -> VerticalMerge { + let Some(properties) = child(cell_node, "tcPr") else { + return VerticalMerge::None; + }; + let Some(vertical_merge) = child(properties, "vMerge") else { + return VerticalMerge::None; + }; + // `` and `w:val="continue"` continue the merge above; only an + // explicit `restart` opens a new vertical span. + match vertical_merge.attr("val") { + Some("restart") => VerticalMerge::Restart, + _ => VerticalMerge::Continue, + } +} + +fn row_is_header(row_node: &XmlNode) -> bool { + child(row_node, "trPr") + .and_then(|properties| child(properties, "tblHeader")) + .is_some_and(|header| !matches!(header.attr("val"), Some("false" | "0" | "off"))) +} + +fn drawing_sizing(drawing: &XmlNode) -> InputImageSizing { + let Some(extent) = find_descendant(drawing, "extent") else { + return InputImageSizing::Intrinsic; + }; + let Some(width_px) = extent.attr("cx").and_then(emu_to_px) else { + return InputImageSizing::Intrinsic; + }; + InputImageSizing::Fixed { + width_px, + height_px: extent.attr("cy").and_then(emu_to_px), + } +} + +fn emu_to_px(value: &str) -> Option { + let emu = value.parse::().ok()?; + if emu <= 0 { + return None; + } + u32::try_from(emu.saturating_mul(96) / 914_400).ok() +} + +fn asset_id_from_bytes(bytes: &[u8]) -> AssetId { + AssetId(u128::from(AssetRecord::stable_content_hash(bytes))) +} + +fn mime_from_path(path: &str) -> &'static str { + let extension = path + .rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + match extension.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "bmp" => "image/bmp", + "tif" | "tiff" => "image/tiff", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "emf" => "image/emf", + "wmf" => "image/wmf", + _ => "application/octet-stream", + } +} + +fn file_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_owned() +} + +// -- Lightweight DOM with byte spans --------------------------------------- + +/// A minimal element tree. `start..end` spans the element (from the opening `<` +/// of its start tag to just past its end tag) in the source XML, so equation +/// subtrees can be handed verbatim to [`omml`]. +struct XmlNode { + local: String, + attrs: Vec<(String, String)>, + text: String, + children: Vec, + start: usize, + end: usize, +} + +impl XmlNode { + fn attr(&self, local: &str) -> Option<&str> { + self + .attrs + .iter() + .find(|entry| entry.0 == local) + .map(|entry| entry.1.as_str()) + } +} + +fn parse_tree(xml: &[u8]) -> Option { + let mut reader = XmlReader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut root: Option = None; + + loop { + // Captured before the read: for a tag event this is the offset of its `<`. + let start = reader.buffer_position() as usize; + match reader.read_event_into(&mut buf) { + Ok(Event::Start(event)) => { + let mut node = node_from_start(&event); + node.start = start; + stack.push(node); + }, + Ok(Event::Empty(event)) => { + let mut node = node_from_start(&event); + node.start = start; + node.end = reader.buffer_position() as usize; + push_node(&mut stack, &mut root, node); + }, + Ok(Event::Text(event)) => { + if let Some(top) = stack.last_mut() + && let Ok(text) = event.unescape() + { + top.text.push_str(&text); + } + }, + Ok(Event::End(_)) => { + if let Some(mut node) = stack.pop() { + node.end = reader.buffer_position() as usize; + push_node(&mut stack, &mut root, node); + } + }, + Ok(Event::Eof) => break, + Err(_) => return None, + _ => {}, + } + buf.clear(); + } + + root +} + +fn push_node(stack: &mut [XmlNode], root: &mut Option, node: XmlNode) { + if let Some(parent) = stack.last_mut() { + parent.children.push(node); + } else { + root.get_or_insert(node); + } +} + +fn node_from_start(event: &BytesStart<'_>) -> XmlNode { + let local = local_name(event.name().as_ref()).to_owned(); + let mut attrs = Vec::new(); + for attribute in event.attributes().flatten() { + let key = local_name(attribute.key.as_ref()).to_owned(); + let value = String::from_utf8_lossy(attribute.value.as_ref()).into_owned(); + attrs.push((key, value)); + } + XmlNode { + local, + attrs, + text: String::new(), + children: Vec::new(), + start: 0, + end: 0, + } +} + +fn local_name(name: &[u8]) -> &str { + let name = std::str::from_utf8(name).unwrap_or_default(); + name.rsplit(':').next().unwrap_or(name) +} + +fn child<'tree>(node: &'tree XmlNode, local: &str) -> Option<&'tree XmlNode> { + node + .children + .iter() + .find(|candidate| candidate.local == local) +} + +fn find_descendant<'tree>(node: &'tree XmlNode, local: &str) -> Option<&'tree XmlNode> { + for candidate in &node.children { + if candidate.local == local { + return Some(candidate); + } + if let Some(found) = find_descendant(candidate, local) { + return Some(found); + } + } + None +} + +fn find_descendant_with_attr<'tree>(node: &'tree XmlNode, local: &str, attr: &str) -> Option<&'tree XmlNode> { + for candidate in &node.children { + if candidate.local == local && candidate.attr(attr).is_some() { + return Some(candidate); + } + if let Some(found) = find_descendant_with_attr(candidate, local, attr) { + return Some(found); + } + } + None +} + +fn collect_descendants<'tree>(node: &'tree XmlNode, local: &str, out: &mut Vec<&'tree XmlNode>) { + for candidate in &node.children { + if candidate.local == local { + out.push(candidate); + } else { + collect_descendants(candidate, local, out); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc_xml(body: &str) -> Vec { + format!( + r#"{body}"# + ) + .into_bytes() + } + + fn cleaned(body: &str) -> CleanedDocx { + let xml = doc_xml(body); + CleanedDocx { + bytes: Vec::new(), + main_document_xml: Some(xml), + report: crate::cleaner::DocxCleanReport { + stats: crate::cleaner::DocxCleanStats::default(), + actions: crate::cleaner::CLEANING_RULES, + }, + } + } + + fn normal(text: &str) -> DocumentParagraphInput { + DocumentParagraphInput { + style: ParagraphStyle::Normal, + runs: vec![flowstate_document::DocumentRunInput { + text: text.to_owned(), + styles: RunStyles::default(), + }], + } + } + + #[test] + fn table_with_paragraph_cell_is_emitted_in_body_order() { + let body = r#"introleftright"#; + let cleaned = cleaned(body); + let paragraphs = [normal("intro")]; + + let structured = interpret_structured(&cleaned, ¶graphs).expect("structured import"); + + assert_eq!(structured.tables_imported, 1); + assert!(matches!(structured.blocks.first(), Some(InputBlock::Paragraph(_)))); + let Some(InputBlock::Table(table)) = structured.blocks.get(1) else { + panic!("expected a table block after the intro paragraph"); + }; + assert!(table.style.header_row); + assert_eq!( + table.column_widths, + vec![InputTableColumnWidth::FixedPx(96), InputTableColumnWidth::FixedPx(192)] + ); + assert_eq!(table.rows.len(), 1); + assert_eq!(table.rows[0].cells.len(), 2); + let InputTableCellBlock::Paragraph(left) = &table.rows[0].cells[0].blocks[0] else { + panic!("expected a paragraph in the first cell"); + }; + assert_eq!(left.runs[0].text, "left"); + } + + #[test] + fn vertical_merge_folds_continuation_into_row_span() { + let body = r#"top"#; + let cleaned = cleaned(body); + + let structured = interpret_structured(&cleaned, &[]).expect("structured import"); + + let Some(InputBlock::Table(table)) = structured.blocks.first() else { + panic!("expected a table block"); + }; + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].cells.len(), 1); + assert_eq!(table.rows[0].cells[0].row_span, 2); + assert!(table.rows[1].cells.is_empty()); + } + + #[test] + fn inline_office_math_emits_equation_after_paragraph() { + let body = r"see12"; + let cleaned = cleaned(body); + let paragraphs = [normal("see")]; + + let structured = interpret_structured(&cleaned, ¶graphs).expect("structured import"); + + assert_eq!(structured.equations_imported, 1); + assert!(matches!(structured.blocks.first(), Some(InputBlock::Paragraph(_)))); + let Some(InputBlock::Equation(equation)) = structured.blocks.get(1) else { + panic!("expected an equation block after the paragraph"); + }; + assert_eq!(equation.source, "\\frac{1}{2}"); + } + + #[test] + fn body_without_objects_reports_zero_counts() { + let cleaned = cleaned(r"plain"); + let structured = interpret_structured(&cleaned, &[normal("plain")]).expect("structured import"); + + assert_eq!(structured.tables_imported, 0); + assert_eq!(structured.images_imported, 0); + assert_eq!(structured.equations_imported, 0); + assert!(structured.assets.is_empty()); + assert_eq!(structured.blocks.len(), 1); + } +} diff --git a/crates/flowstate-docx/src/lib.rs b/crates/flowstate-docx/src/lib.rs index 1b19e348..85b67dff 100644 --- a/crates/flowstate-docx/src/lib.rs +++ b/crates/flowstate-docx/src/lib.rs @@ -10,15 +10,19 @@ pub use cleaner::{CleanAction, CleanedDocx, DocxCleanReport, DocxCleanStats, cle pub use exporter::{convert_db8_to_docx, write_docx}; pub use interpreter::{ DocxConversionReport, RecognitionRule, convert_cleaned_docx_to_document, convert_docx_bytes_to_document, convert_docx_to_document, + import_cleaned_docx_to_loro, import_docx_bytes_to_loro, import_docx_to_loro, }; pub use pdf::{convert_db8_to_pdf, convert_docx_to_pdf, write_pdf, write_pdf_with_db8_bytes}; pub use pdf_recovery::{FlowstatePdfPayloadInfo, convert_pdf_to_db8, embed_db8_bytes_in_pdf, embed_db8_file_in_pdf, extract_db8_bytes_from_pdf}; -use flowstate_document::write_db8; - #[hotpath::measure] pub fn convert_docx_to_db8(input: impl AsRef, output: impl AsRef) -> io::Result { - let (document, report) = convert_docx_to_document(input)?; - write_db8(output, &document)?; + let (imported, report) = import_docx_to_loro(input, "Imported DOCX")?; + flowstate_document::DocumentPackage::from_loro_snapshot_with_assets( + &imported.doc, + "Imported DOCX", + flowstate_document::loro_import::assets_from_document(&imported.projection), + )? + .write(output)?; Ok(report) } diff --git a/crates/flowstate-docx/src/pdf.rs b/crates/flowstate-docx/src/pdf.rs index a7a3f06b..233b9db7 100644 --- a/crates/flowstate-docx/src/pdf.rs +++ b/crates/flowstate-docx/src/pdf.rs @@ -1,13 +1,16 @@ use std::{ fs, io, path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; -use flowstate_document::{Document, db8_bytes, read_db8}; +use flowstate_document::{DocumentProjection, read_db8}; use crate::{embed_db8_bytes_in_pdf, write_docx}; +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + #[hotpath::measure] pub fn convert_docx_to_pdf(input: impl AsRef, output: impl AsRef) -> io::Result<()> { let input = input.as_ref(); @@ -19,17 +22,37 @@ pub fn convert_docx_to_pdf(input: impl AsRef, output: impl AsRef) -> fs::create_dir_all(parent)?; } - docxide_pdf::convert_docx_to_pdf(input, output).map_err(|error| io::Error::other(format!("failed to convert DOCX to PDF: {error}"))) + let temp_output = temp_sibling_path(output, "pdf"); + let result = (|| { + docxide_pdf::convert_docx_to_pdf(input, &temp_output) + .map_err(|error| io::Error::other(format!("failed to convert DOCX to PDF: {error}")))?; + fs::rename(&temp_output, output)?; + Ok(()) + })(); + let _ = fs::remove_file(&temp_output); + result } #[hotpath::measure] -pub fn write_pdf(path: impl AsRef, document: &Document) -> io::Result<()> { - let db8 = db8_bytes(document)?; - write_pdf_with_db8_bytes(path, document, &db8) +pub fn write_pdf(path: impl AsRef, document: &DocumentProjection) -> io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let temp_docx = temp_sibling_path(path, "docx"); + let result = (|| { + write_docx(&temp_docx, document)?; + convert_docx_to_pdf(&temp_docx, path) + })(); + let _ = fs::remove_file(&temp_docx); + result } #[hotpath::measure] -pub fn write_pdf_with_db8_bytes(path: impl AsRef, document: &Document, db8_bytes: &[u8]) -> io::Result<()> { +pub fn write_pdf_with_db8_bytes(path: impl AsRef, document: &DocumentProjection, db8_bytes: &[u8]) -> io::Result<()> { let path = path.as_ref(); if let Some(parent) = path .parent() @@ -71,7 +94,21 @@ fn temp_sibling_path(target: &Path, extension: &str) -> PathBuf { .unwrap_or("flowstate-export"); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - dir.join(format!(".{stem}.{nanos}.tmp.{extension}")) + .map_or(0, |duration| duration.as_nanos()); + let pid = std::process::id(); + let sequence = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + dir.join(format!(".{stem}.{pid}.{nanos}.{sequence}.tmp.{extension}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_sibling_path_uses_unique_names() { + let path = Path::new("exports/report.pdf"); + let a = temp_sibling_path(path, "pdf"); + let b = temp_sibling_path(path, "pdf"); + assert_ne!(a, b); + } } diff --git a/crates/flowstate-docx/src/pdf_recovery.rs b/crates/flowstate-docx/src/pdf_recovery.rs index 8c45d893..ac165827 100644 --- a/crates/flowstate-docx/src/pdf_recovery.rs +++ b/crates/flowstate-docx/src/pdf_recovery.rs @@ -1,13 +1,24 @@ -use std::{fs, io, path::Path}; +use std::{ + fs, + io::{self, Cursor, Read}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; +use flowstate_document::DocumentPackage; use lopdf::{Dictionary, Document as PdfDocument, Object, ObjectId, Stream, dictionary}; -const PAYLOAD_NAME: &str = "flowstate-source.db8.zst"; -const PAYLOAD_DESCRIPTION: &str = "Flowstate DB8 source document"; -const PAYLOAD_MIME_TYPE: &str = "application/x-flowstate-db8+zstd"; -const PAYLOAD_MAGIC: &[u8; 8] = b"FSDB8ZST"; +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +const PAYLOAD_NAME: &str = "flowstate-loro-source.db8.zst"; +const PAYLOAD_DESCRIPTION: &str = "Flowstate Loro-native DB8 source package"; +const PAYLOAD_MIME_TYPE: &str = "application/x-flowstate-loro-db8+zstd"; +const PAYLOAD_MAGIC: &[u8; 8] = b"FSL8ZST\0"; const PAYLOAD_VERSION: u32 = 1; const ZSTD_LEVEL: i32 = 3; +const MAX_PAYLOAD_COMPRESSED_LEN: usize = 64 * 1024 * 1024; +const MAX_PAYLOAD_UNCOMPRESSED_LEN: usize = 256 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FlowstatePdfPayloadInfo { @@ -39,16 +50,22 @@ pub fn embed_db8_bytes_in_pdf( fs::create_dir_all(parent)?; } + let temp_output_pdf = temp_sibling_path(output_pdf, "pdf"); let payload = encode_payload(db8_bytes)?; let info = FlowstatePdfPayloadInfo { original_len: db8_bytes.len() as u64, compressed_len: payload.len() as u64, }; - let mut pdf = PdfDocument::load(input_pdf).map_err(pdf_error)?; - attach_payload(&mut pdf, payload, db8_bytes.len() as u64)?; - pdf.save(output_pdf)?; - Ok(info) + let result = (|| { + let mut pdf = PdfDocument::load(input_pdf).map_err(pdf_error)?; + attach_payload(&mut pdf, payload, db8_bytes.len() as u64)?; + pdf.save(&temp_output_pdf)?; + fs::rename(&temp_output_pdf, output_pdf)?; + Ok(info) + })(); + let _ = fs::remove_file(&temp_output_pdf); + result } #[hotpath::measure] @@ -65,17 +82,12 @@ pub fn convert_pdf_to_db8(input_pdf: impl AsRef, output_db8: impl AsRef io::Result> { + DocumentPackage::from_bytes(db8_bytes)?; let compressed = zstd::bulk::compress(db8_bytes, ZSTD_LEVEL).map_err(io::Error::other)?; let mut payload = Vec::with_capacity(PAYLOAD_MAGIC.len() + 4 + 8 + compressed.len()); payload.extend_from_slice(PAYLOAD_MAGIC); @@ -111,10 +123,32 @@ fn decode_payload(payload: &[u8]) -> io::Result> { .try_into() .expect("length slice has fixed length"), ); - let decompressed = zstd::bulk::decompress(&payload[header_len..], original_len as usize).map_err(io::Error::other)?; - if decompressed.len() as u64 != original_len { + let compressed = &payload[header_len..]; + if compressed.len() > MAX_PAYLOAD_COMPRESSED_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Flowstate PDF payload exceeds the compressed size limit", + )); + } + let original_len = usize::try_from(original_len) + .ok() + .filter(|len| *len <= MAX_PAYLOAD_UNCOMPRESSED_LEN) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Flowstate PDF payload exceeds the uncompressed size limit"))?; + + let mut decompressed = vec![0; original_len]; + let mut decoder = zstd::stream::read::Decoder::new(Cursor::new(compressed)).map_err(io::Error::other)?; + decoder.read_exact(&mut decompressed).map_err(|error| { + if error.kind() == io::ErrorKind::UnexpectedEof { + io::Error::new(io::ErrorKind::InvalidData, "Flowstate PDF payload length mismatch") + } else { + error + } + })?; + let mut extra = [0u8; 1]; + if decoder.read(&mut extra).map_err(io::Error::other)? != 0 { return Err(io::Error::new(io::ErrorKind::InvalidData, "Flowstate PDF payload length mismatch")); } + DocumentPackage::from_bytes(&decompressed)?; Ok(decompressed) } @@ -274,3 +308,57 @@ fn object_string_equals(object: &Object, expected: &[u8]) -> bool { fn pdf_error(error: lopdf::Error) -> io::Error { io::Error::other(error) } + +#[hotpath::measure] +fn temp_sibling_path(target: &Path, extension: &str) -> PathBuf { + let dir = target + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let stem = target + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("flowstate-export"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let pid = std::process::id(); + let sequence = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + dir.join(format!(".{stem}.{pid}.{nanos}.{sequence}.tmp.{extension}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_payload_rejects_length_mismatch() { + let doc = flowstate_document::loro_schema::new_loro_document("payload").expect("new document"); + let bytes = DocumentPackage::from_loro_snapshot(&doc, "payload") + .expect("package") + .to_bytes() + .expect("bytes"); + let mut payload = encode_payload(&bytes).expect("payload"); + let len_offset = PAYLOAD_MAGIC.len() + 4; + let original_len = u64::from_be_bytes( + payload[len_offset..len_offset + 8] + .try_into() + .expect("fixed length"), + ); + payload[len_offset..len_offset + 8].copy_from_slice(&(original_len - 1).to_be_bytes()); + + let error = decode_payload(&payload).expect_err("mismatch must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn decode_payload_rejects_oversized_uncompressed_length() { + let mut payload = Vec::new(); + payload.extend_from_slice(PAYLOAD_MAGIC); + payload.extend_from_slice(&PAYLOAD_VERSION.to_be_bytes()); + payload.extend_from_slice(&((MAX_PAYLOAD_UNCOMPRESSED_LEN as u64) + 1).to_be_bytes()); + + let error = decode_payload(&payload).expect_err("oversized payload must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/crates/flowstate-flow/Cargo.toml b/crates/flowstate-flow/Cargo.toml index 1333a38f..50935679 100644 --- a/crates/flowstate-flow/Cargo.toml +++ b/crates/flowstate-flow/Cargo.toml @@ -13,6 +13,7 @@ hotpath = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tracing = { workspace = true } uuid = { workspace = true } [features] diff --git a/crates/flowstate-flow/src/actions.rs b/crates/flowstate-flow/src/actions.rs index c05add08..927dfb91 100644 --- a/crates/flowstate-flow/src/actions.rs +++ b/crates/flowstate-flow/src/actions.rs @@ -56,6 +56,9 @@ impl FlowDocument { pub fn apply_action(&mut self, action: Action) -> Action { match action { Action::Add { parent, id, index, value } => { + if self.nodes.contains_key(&id) { + return Action::Identity; + } let Some(parent_node) = self.nodes.get(&parent) else { return Action::Identity; }; @@ -74,6 +77,13 @@ impl FlowDocument { Action::Delete { id } }, Action::Delete { id } => { + let inverse_replace = self + .nodes + .get(&id) + .is_some_and(|node| !node.children.is_empty()) + .then(|| Action::Replace { + new_nodes: self.nodes.clone(), + }); let Some(node) = self.nodes.get(&id).cloned() else { return Action::Identity; }; @@ -87,13 +97,13 @@ impl FlowDocument { return Action::Identity; }; parent.children.remove(index); - self.nodes.remove(&id); - Action::Add { + remove_subtree(&mut self.nodes, &id); + inverse_replace.unwrap_or(Action::Add { parent: parent_id, id, index, value: node.value, - } + }) }, Action::Update { id, new_value } => { let Some(node) = self.nodes.get_mut(&id) else { @@ -145,6 +155,20 @@ impl FlowDocument { } } +/// Removes `id` and all of its descendants from `nodes`. +/// +/// Removing each node before recursing into its (former) children keeps the +/// walk cycle-safe: a malformed child edge pointing back at an already-removed +/// ancestor short-circuits on the `remove` miss instead of recursing forever. +#[hotpath::measure] +fn remove_subtree(nodes: &mut Nodes, id: &NodeId) { + if let Some(node) = nodes.remove(id) { + for child in &node.children { + remove_subtree(nodes, child); + } + } +} + #[hotpath::measure] pub fn new_box_action(parent: NodeId, parent_flow_id: NodeId, index: usize, placeholder: Option) -> Action { Action::Add { @@ -163,7 +187,6 @@ pub fn new_box_action(parent: NodeId, parent_flow_id: NodeId, index: usize, plac } } -#[hotpath::measure] pub const fn new_extension_action(parent: NodeId, parent_flow_id: NodeId, id: NodeId) -> Action { Action::Add { parent, @@ -181,7 +204,6 @@ pub const fn new_extension_action(parent: NodeId, parent_flow_id: NodeId, id: No } } -#[hotpath::measure] #[must_use] pub const fn new_update_action(id: NodeId, new_value: NodeValue) -> Action { Action::Update { id, new_value } diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs index f8b3b31e..2d243587 100644 --- a/crates/flowstate-flow/src/document.rs +++ b/crates/flowstate-flow/src/document.rs @@ -269,7 +269,6 @@ pub fn constrain_index(index: usize, len: usize) -> usize { index.min(len) } -#[hotpath::measure] const fn is_false(value: &bool) -> bool { !*value } diff --git a/crates/flowstate-flow/src/history.rs b/crates/flowstate-flow/src/history.rs index b3c2bbb9..64cbf884 100644 --- a/crates/flowstate-flow/src/history.rs +++ b/crates/flowstate-flow/src/history.rs @@ -16,7 +16,6 @@ pub struct History { actions: Vec, } -#[hotpath::measure_all] impl History { pub fn add(&mut self, action_bundle: ActionBundle, before_focus: Option, after_focus: Option) { let keep = self.index.map_or(0, |index| index + 1); diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index 339e9093..5a5c7c5f 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -1,16 +1,18 @@ -use std::fs; -use std::path::Path; +use std::{fs, path::Path}; use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use uuid::Uuid; use crate::actions::{Action, new_box_action}; use crate::document::{BoxNode, Flow, FlowDocument, NodeId, NodeValue, Nodes, ROOT_ID, new_box_id, new_flow_id}; pub const CURRENT_SAVE_VERSION: u32 = 1; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +const MAX_FL0_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct SaveableFlowDocument { pub nodes: Nodes, pub version: u32, @@ -45,6 +47,15 @@ pub fn load_nodes(data: Value) -> Result { #[hotpath::measure] pub fn load_flow_document(path: impl AsRef) -> Result { let path = path.as_ref(); + let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + if metadata.len() > MAX_FL0_BYTES { + bail!( + "refusing to read {}: file too large ({} bytes > {} bytes)", + path.display(), + metadata.len(), + MAX_FL0_BYTES + ); + } let text = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let value: Value = serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?; let nodes = load_nodes(value)?; @@ -53,7 +64,28 @@ pub fn load_flow_document(path: impl AsRef) -> Result { #[hotpath::measure] pub fn load_flow_document_or_new(path: impl AsRef) -> FlowDocument { - load_flow_document(path).unwrap_or_else(|_| FlowDocument::new()) + let path = path.as_ref(); + match load_flow_document(path) { + Ok(document) => document, + Err(error) => { + if path.exists() { + let quarantine = path.with_file_name(format!( + "{}.corrupt.{}", + path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("flowstate.fl0"), + Uuid::new_v4() + )); + if let Err(rename_error) = fs::rename(path, &quarantine) { + tracing::warn!(path = %path.display(), quarantine = %quarantine.display(), error = %format_args!("{error:#}"), rename_error = %rename_error, "failed to quarantine existing .fl0 document; using a new empty document"); + } else { + tracing::warn!(path = %path.display(), quarantine = %quarantine.display(), error = %format_args!("{error:#}"), "failed to load existing .fl0 document; quarantined it and using a new empty document"); + } + } + FlowDocument::new() + }, + } } #[hotpath::measure] @@ -66,7 +98,17 @@ pub fn save_flow_document(path: impl AsRef, document: &FlowDocument) -> Re fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; } let text = get_json(document)?; - fs::write(path, text).with_context(|| format!("failed to write {}", path.display())) + let temp_path = path.with_file_name(format!( + ".{}.{}.tmp", + path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("flowstate"), + Uuid::new_v4() + )); + fs::write(&temp_path, text).with_context(|| format!("failed to write temporary {}", temp_path.display()))?; + fs::rename(&temp_path, path).with_context(|| format!("failed to atomically replace {} with {}", path.display(), temp_path.display()))?; + Ok(()) } #[derive(Clone, Debug, Deserialize)] @@ -97,54 +139,78 @@ struct OldBox { #[hotpath::measure] fn upgrade_0_1(saved: Value) -> Result { + #[derive(Debug)] + enum LegacyNode { + Flow(OldFlow), + Box(OldBox), + } + let old_flows: Vec = serde_json::from_value(saved).context("invalid legacy debate-flow document")?; let mut document = FlowDocument::new(); - for (index, old_flow) in old_flows.into_iter().enumerate() { - let flow_id = new_flow_id(); - let add_flow = Action::Add { - parent: ROOT_ID.to_owned(), - id: flow_id.clone(), - index, - value: NodeValue::Flow(Flow { - content: old_flow.content, - invert: old_flow.invert, - columns: old_flow.columns, - }), - }; - document.apply_action(add_flow); - for (box_index, old_box) in old_flow.children.into_iter().enumerate() { - upgrade_0_1_add_boxes_rec(&mut document, flow_id.clone(), flow_id.clone(), old_box, box_index); + let mut pending = Vec::new(); + for (index, old_flow) in old_flows.into_iter().enumerate().rev() { + pending.push((ROOT_ID.to_owned(), index, LegacyNode::Flow(old_flow))); + } + + let mut processed_nodes = 0usize; + const MAX_LEGACY_NODES: usize = 100_000; + while let Some((parent_id, index, node)) = pending.pop() { + processed_nodes += 1; + if processed_nodes > MAX_LEGACY_NODES { + bail!("legacy .fl0 document is too deeply nested or large to upgrade safely"); + } + + match node { + LegacyNode::Flow(old_flow) => { + let flow_id = new_flow_id(); + let add_flow = Action::Add { + parent: parent_id, + id: flow_id.clone(), + index, + value: NodeValue::Flow(Flow { + content: old_flow.content, + invert: old_flow.invert, + columns: old_flow.columns, + }), + }; + document.apply_action(add_flow); + for (box_index, child) in old_flow.children.into_iter().enumerate().rev() { + pending.push((flow_id.clone(), box_index, LegacyNode::Box(child))); + } + }, + LegacyNode::Box(old_box) => { + let id = new_box_id(); + let flow_id = document + .parent_flow_id(&parent_id) + .unwrap_or_else(|| parent_id.clone()); + let add = Action::Add { + parent: parent_id, + id: id.clone(), + index, + value: NodeValue::Box(BoxNode { + content: old_box.content, + flow_id: flow_id.clone(), + placeholder: old_box.placeholder, + empty: old_box.empty, + crossed: old_box.crossed, + bold: false, + is_extension: false, + }), + }; + document.apply_action(add); + for (child_index, child) in old_box.children.into_iter().enumerate().rev() { + pending.push((id.clone(), child_index, LegacyNode::Box(child))); + } + }, } } + Ok(SaveableFlowDocument { nodes: document.nodes, version: CURRENT_SAVE_VERSION, }) } -#[hotpath::measure] -fn upgrade_0_1_add_boxes_rec(document: &mut FlowDocument, flow_id: NodeId, parent_id: NodeId, old_box: OldBox, index: usize) { - let id = new_box_id(); - let add = Action::Add { - parent: parent_id, - id: id.clone(), - index, - value: NodeValue::Box(BoxNode { - content: old_box.content, - flow_id: flow_id.clone(), - placeholder: old_box.placeholder, - empty: old_box.empty, - crossed: old_box.crossed, - bold: false, - is_extension: false, - }), - }; - document.apply_action(add); - for (child_index, child) in old_box.children.into_iter().enumerate() { - upgrade_0_1_add_boxes_rec(document, flow_id.clone(), id.clone(), child, child_index); - } -} - #[allow(dead_code, reason = "Legacy save migration helper is retained for older persisted flow documents.")] #[hotpath::measure] fn _new_empty_box(parent_id: NodeId, flow_id: NodeId, index: usize) -> Action { @@ -154,6 +220,49 @@ fn _new_empty_box(parent_id: NodeId, flow_id: NodeId, index: usize) -> Action { #[cfg(test)] mod tests { use super::*; + use std::{path::PathBuf, time::SystemTime}; + + fn test_dir(name: &str) -> PathBuf { + let unique = format!( + "{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn rejects_oversized_documents_before_reading() { + let dir = test_dir("flowstate-fl0-size"); + let path = dir.join("oversized.fl0"); + fs::write(&path, vec![b'{'; (MAX_FL0_BYTES as usize) + 1]).unwrap(); + + let error = load_flow_document(&path).unwrap_err(); + assert!(error.to_string().contains("file too large")); + } + + #[test] + fn quarantines_bad_existing_document_on_fallback() { + let dir = test_dir("flowstate-fl0-corrupt"); + let path = dir.join("broken.fl0"); + fs::write(&path, b"{not json").unwrap(); + + let document = load_flow_document_or_new(&path); + assert!(document.flow_ids().is_empty()); + assert!(!path.exists()); + assert!(fs::read_dir(&dir).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("broken.fl0.corrupt.") + })); + } #[test] #[hotpath::measure] diff --git a/crates/flowstate-tub/src/lib.rs b/crates/flowstate-tub/src/lib.rs index c8028e24..df48935a 100644 --- a/crates/flowstate-tub/src/lib.rs +++ b/crates/flowstate-tub/src/lib.rs @@ -9,8 +9,8 @@ use std::{ use anyhow::{Context as _, Result}; use flowstate_document::{ - Document, DocumentSection, InputParagraph, InputRun, SectionId, SectionKind, document_text_slice, paragraph_byte_range, - paragraph_index_for_id, paragraph_text_len, read_db8, + DocumentPackage, DocumentProjection, InputParagraph, InputRun, SearchUnitChunk, document_text_slice, paragraph_byte_range, paragraph_text_len, + read_db8, }; use ignore::WalkBuilder; use notify::{ @@ -27,7 +27,7 @@ use tantivy::{ }; const CATALOG_FILE: &str = "catalog.sqlite3"; -const TANTIVY_DIR: &str = "tantivy-v1"; +const TANTIVY_DIR: &str = "tantivy-v2"; const FILENAME_TOKENIZER: &str = "filename_ngram"; const WRITER_MEMORY_BYTES: usize = 96_000_000; @@ -49,7 +49,7 @@ impl FileKind { } } -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] pub enum SearchUnitKind { File, Pocket, @@ -60,13 +60,18 @@ pub enum SearchUnitKind { Card, Cite, Paragraph, + ImageAlt, + ImageCaption, + Equation, + TableCell, FlowNode, Document, + Unknown(String), } impl SearchUnitKind { #[must_use] - pub const fn as_str(self) -> &'static str { + pub fn as_str(&self) -> &str { match self { Self::File => "file", Self::Pocket => "pocket", @@ -77,38 +82,35 @@ impl SearchUnitKind { Self::Card => "card", Self::Cite => "cite", Self::Paragraph => "paragraph", + Self::ImageAlt => "image_alt", + Self::ImageCaption => "image_caption", + Self::Equation => "equation", + Self::TableCell => "table_cell", Self::FlowNode => "flow_node", Self::Document => "document", + Self::Unknown(value) => value.as_str(), } } fn from_str(value: &str) -> Option { - match value { - "file" => Some(Self::File), - "pocket" => Some(Self::Pocket), - "hat" => Some(Self::Hat), - "block" => Some(Self::BlockSection), - "tag" => Some(Self::TagSection), - "analytic" => Some(Self::Analytic), - "card" => Some(Self::Card), - "cite" => Some(Self::Cite), - "paragraph" => Some(Self::Paragraph), - "flow_node" => Some(Self::FlowNode), - "document" => Some(Self::Document), - _ => None, - } - } - - const fn from_section(kind: SectionKind) -> Self { - match kind { - SectionKind::Custom(0) => Self::Pocket, - SectionKind::Custom(1) => Self::Hat, - SectionKind::Custom(2) => Self::BlockSection, - SectionKind::Custom(3) => Self::TagSection, - SectionKind::Custom(4) => Self::Analytic, - SectionKind::Custom(5) => Self::Card, - SectionKind::Custom(_) => Self::Paragraph, - } + Some(match value { + "file" => Self::File, + "pocket" => Self::Pocket, + "hat" => Self::Hat, + "block" => Self::BlockSection, + "tag" => Self::TagSection, + "analytic" => Self::Analytic, + "card" => Self::Card, + "cite" => Self::Cite, + "paragraph" => Self::Paragraph, + "image_alt" => Self::ImageAlt, + "image_caption" => Self::ImageCaption, + "equation" => Self::Equation, + "table_cell" => Self::TableCell, + "flow_node" => Self::FlowNode, + "document" => Self::Document, + _ => Self::Unknown(value.to_owned()), + }) } } @@ -155,6 +157,10 @@ pub struct SearchHit { pub score: f32, pub paragraph_start: Option, pub paragraph_end_exclusive: Option, + #[serde(default)] + pub paragraph_start_cursor: Option>, + #[serde(default)] + pub paragraph_end_cursor: Option>, } #[derive(Clone, Debug)] @@ -230,14 +236,14 @@ impl TubIndex { let path = canonicalize_file(path)?; seen_paths.insert(path.clone()); let metadata = fs::metadata(&path)?; + let size_bytes = metadata.len(); + let modified_ns = modified_ns(&metadata); let display_path = display_path_for(&self.root, &path); let parent_display_path = parent_display_path(&display_path); let file_name = path .file_name() .map_or_else(|| display_path.clone(), |name| name.to_string_lossy().to_string()); - let size_bytes = metadata.len(); - let modified_ns = modified_ns(&metadata); - let fingerprint = fingerprint(size_bytes, modified_ns); + let fingerprint = fingerprint(size_bytes, modified_ns, kind, &path)?; let existing = existing.get(&path); let file_id = existing.map_or_else(|| stable_file_id(&self.root, &path), |record| record.file_id.clone()); @@ -382,7 +388,7 @@ impl TubIndex { } else { kinds }; - let allowed = kinds.iter().copied().collect::>(); + let allowed = kinds.iter().cloned().collect::>(); let mut hits = Vec::with_capacity(limit); for file in self.list_files()? { @@ -435,7 +441,7 @@ impl TubIndex { &query, &tantivy::collector::TopDocs::with_limit(limit.saturating_mul(8).max(limit)).order_by_score(), )?; - let allowed = allowed_kinds.iter().copied().collect::>(); + let allowed = allowed_kinds.iter().cloned().collect::>(); let mut hits = Vec::new(); for (score, address) in top_docs { @@ -461,7 +467,14 @@ impl TubIndex { if !hit.preview_paragraphs.is_empty() { return Ok(()); } + if hit.paragraph_start_cursor.is_some() && !hit.insert_text.trim().is_empty() { + hit.preview_paragraphs = vec![preview_paragraph_from_text(&hit.insert_text)]; + return Ok(()); + } let Some(start) = hit.paragraph_start else { + if !hit.insert_text.trim().is_empty() { + hit.preview_paragraphs = vec![preview_paragraph_from_text(&hit.insert_text)]; + } return Ok(()); }; let Some(end) = hit.paragraph_end_exclusive else { @@ -686,6 +699,8 @@ struct TubSchema { insert_text: Field, paragraph_start: Field, paragraph_end: Field, + paragraph_start_cursor: Field, + paragraph_end_cursor: Field, size_bytes: Field, modified_ns: Field, } @@ -740,6 +755,8 @@ impl From for SearchHit { score: 0.0, paragraph_start: None, paragraph_end_exclusive: None, + paragraph_start_cursor: None, + paragraph_end_cursor: None, } } } @@ -759,6 +776,8 @@ struct IndexUnit { insert_text: String, paragraph_start: Option, paragraph_end_exclusive: Option, + paragraph_start_cursor: Option>, + paragraph_end_cursor: Option>, } struct FileDocumentInput<'input> { @@ -794,6 +813,8 @@ fn build_schema() -> (Schema, TubSchema) { let insert_text = builder.add_text_field("insert_text", STORED); let paragraph_start = builder.add_text_field("paragraph_start", STORED); let paragraph_end = builder.add_text_field("paragraph_end", STORED); + let paragraph_start_cursor = builder.add_text_field("paragraph_start_cursor", STORED); + let paragraph_end_cursor = builder.add_text_field("paragraph_end_cursor", STORED); let size_bytes = builder.add_u64_field("size_bytes", STORED); let modified_ns = builder.add_u64_field("modified_ns", STORED); let schema = builder.build(); @@ -812,6 +833,8 @@ fn build_schema() -> (Schema, TubSchema) { insert_text, paragraph_start, paragraph_end, + paragraph_start_cursor, + paragraph_end_cursor, size_bytes, modified_ns, }; @@ -840,6 +863,8 @@ fn file_document(schema: &TubSchema, input: FileDocumentInput<'_>) -> TantivyDoc schema.insert_text => "", schema.paragraph_start => "", schema.paragraph_end => "", + schema.paragraph_start_cursor => "", + schema.paragraph_end_cursor => "", schema.size_bytes => input.size_bytes, schema.modified_ns => input.modified_ns, ) @@ -861,6 +886,8 @@ fn unit_document(schema: &TubSchema, unit: &IndexUnit) -> TantivyDocument { schema.insert_text => unit.insert_text.as_str(), schema.paragraph_start => unit.paragraph_start.map(|value| value.to_string()).unwrap_or_default(), schema.paragraph_end => unit.paragraph_end_exclusive.map(|value| value.to_string()).unwrap_or_default(), + schema.paragraph_start_cursor => unit.paragraph_start_cursor.as_deref().map(hex_bytes).unwrap_or_default(), + schema.paragraph_end_cursor => unit.paragraph_end_cursor.as_deref().map(hex_bytes).unwrap_or_default(), schema.size_bytes => unit.insert_text.len() as u64, schema.modified_ns => 0_u64, ) @@ -883,6 +910,8 @@ fn hit_from_unit(unit: IndexUnit) -> SearchHit { score: 0.0, paragraph_start: unit.paragraph_start, paragraph_end_exclusive: unit.paragraph_end_exclusive, + paragraph_start_cursor: unit.paragraph_start_cursor, + paragraph_end_cursor: unit.paragraph_end_cursor, } } @@ -910,6 +939,8 @@ fn hit_from_document(schema: &TubSchema, document: &TantivyDocument, score: f32) score, paragraph_start: stored_text(document, schema.paragraph_start).and_then(|value| value.parse::().ok()), paragraph_end_exclusive: stored_text(document, schema.paragraph_end).and_then(|value| value.parse::().ok()), + paragraph_start_cursor: stored_text(document, schema.paragraph_start_cursor).and_then(|value| unhex_bytes(&value)), + paragraph_end_cursor: stored_text(document, schema.paragraph_end_cursor).and_then(|value| unhex_bytes(&value)), }) } @@ -920,103 +951,124 @@ fn stored_text(document: &TantivyDocument, field: Field) -> Option { .map(ToOwned::to_owned) } +fn hex_bytes(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn unhex_bytes(value: &str) -> Option> { + if value.is_empty() { + return None; + } + let mut bytes = Vec::with_capacity(value.len() / 2); + let mut chunks = value.as_bytes().chunks_exact(2); + if !chunks.remainder().is_empty() { + return None; + } + for chunk in &mut chunks { + let text = std::str::from_utf8(chunk).ok()?; + bytes.push(u8::from_str_radix(text, 16).ok()?); + } + Some(bytes) +} + fn db8_index_units(file_id: &str, path: &Path, display_path: &str, file_name: &str) -> Result> { - let document = read_db8(path).with_context(|| format!("reading {}", path.display()))?; - let sections_by_id = document - .sections - .iter() - .map(|section| (section.id, section)) - .collect::>(); - let mut units = Vec::new(); - - for section in document.sections.iter() { - let unit_kind = SearchUnitKind::from_section(section.kind); - if !matches!( - unit_kind, - SearchUnitKind::BlockSection | SearchUnitKind::TagSection | SearchUnitKind::Analytic - ) { - continue; - } - let Some(start) = paragraph_index_for_id(&document, section.start_paragraph) else { - continue; - }; - let end = section_end_index(&document, section).unwrap_or(document.paragraphs.len()); - let end = end.max(start + 1).min(document.paragraphs.len()); - let body = paragraph_range_text(&document, start, end); - let heading = section - .heading_paragraph - .and_then(|id| paragraph_index_for_id(&document, id)) - .map(|ix| paragraph_text_trimmed(&document, ix)) - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| first_non_empty_line(&body).unwrap_or_else(|| unit_kind.as_str().to_owned())); - let heading_path = section_heading_path(&document, §ions_by_id, section); - let cite = cite_for_range(&document, start, end); - let unit_id = format!("{file_id}:{}:{}", unit_kind.as_str(), section.start_paragraph.0); - units.push(IndexUnit { - file_id: file_id.to_owned(), - unit_id, - unit_kind, - path: path.to_path_buf(), - display_path: display_path.to_owned(), - file_name: file_name.to_owned(), - heading_path, - heading, - cite, - body: body.clone(), - insert_text: body, - paragraph_start: Some(start), - paragraph_end_exclusive: Some(end), - }); + if let Some(units) = + DocumentPackage::read_cached_search_units(path).with_context(|| format!("reading cached Flowstate search units {}", path.display()))? + { + return Ok( + units + .iter() + .filter_map(|unit| package_search_unit(file_id, path, display_path, file_name, unit)) + .collect(), + ); } + let mut package = DocumentPackage::read(path).with_context(|| format!("reading Flowstate package {}", path.display()))?; + if package.current_search_units().is_empty() { + let doc = package + .load_loro_doc() + .with_context(|| format!("loading Loro document {}", path.display()))?; + package + .rebuild_search_units_from_loro(&doc) + .with_context(|| format!("rebuilding Loro search units {}", path.display()))?; + } + Ok( + package + .current_search_units() + .iter() + .filter_map(|unit| package_search_unit(file_id, path, display_path, file_name, unit)) + .collect(), + ) +} - Ok(units) +fn package_search_unit(file_id: &str, path: &Path, display_path: &str, file_name: &str, unit: &SearchUnitChunk) -> Option { + let unit_kind = SearchUnitKind::from_str(&unit.unit_kind)?; + let body = unit.body.trim().to_string(); + if body.is_empty() { + return None; + } + let heading = if unit.heading.trim().is_empty() { + first_non_empty_line(&body).unwrap_or_else(|| unit_kind.as_str().to_string()) + } else { + unit.heading.clone() + }; + Some(IndexUnit { + file_id: file_id.to_owned(), + unit_id: format!("{file_id}:loro:{:032x}", unit.unit_id), + unit_kind, + path: path.to_path_buf(), + display_path: display_path.to_owned(), + file_name: file_name.to_owned(), + heading_path: unit.heading_path.clone(), + heading, + cite: None, + body: body.clone(), + insert_text: if unit.insert_text.is_empty() { body } else { unit.insert_text.clone() }, + paragraph_start: None, + paragraph_end_exclusive: None, + paragraph_start_cursor: Some(cursor_for_index(unit)).filter(|cursor| !cursor.is_empty()), + paragraph_end_cursor: Some(end_cursor_for_index(unit)).filter(|cursor| !cursor.is_empty()), + }) } -fn section_end_index(document: &Document, section: &DocumentSection) -> Option { - let id = section.end_paragraph_exclusive?; - paragraph_index_for_id(document, id) +fn cursor_for_index(unit: &SearchUnitChunk) -> Vec { + if unit.paragraph_start_cursor.is_empty() { + unit.unit_start_cursor.clone() + } else { + unit.paragraph_start_cursor.clone() + } } -fn section_heading_path(document: &Document, sections_by_id: &HashMap, section: &DocumentSection) -> Vec { - let mut path = Vec::new(); - let mut current = Some(section.id); - while let Some(section_id) = current { - let Some(section) = sections_by_id.get(§ion_id).copied() else { - break; - }; - if let Some(heading) = section - .heading_paragraph - .and_then(|id| paragraph_index_for_id(document, id)) - .map(|ix| paragraph_text_trimmed(document, ix)) - .filter(|text| !text.is_empty()) - { - path.push(heading); - } - current = section.parent_id; +fn end_cursor_for_index(unit: &SearchUnitChunk) -> Vec { + if unit.paragraph_end_cursor.is_empty() { + unit.unit_end_cursor.clone() + } else { + unit.paragraph_end_cursor.clone() } - path.reverse(); - path } -fn paragraph_range_text(document: &Document, start: usize, end: usize) -> String { - let mut text = String::new(); - for paragraph_ix in start..end { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(¶graph_text_trimmed(document, paragraph_ix)); +fn preview_paragraph_from_text(text: &str) -> InputParagraph { + InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![InputRun { + text: text.to_string(), + styles: flowstate_document::RunStyles::default(), + }], } - text.trim().to_owned() } -fn input_paragraphs_from_document_range(document: &Document, start: usize, end: usize) -> Vec { +fn input_paragraphs_from_document_range(document: &DocumentProjection, start: usize, end: usize) -> Vec { (start..end.min(document.paragraphs.len())) .map(|paragraph_ix| input_paragraph_from_document_range(document, paragraph_ix, 0..paragraph_text_len(&document.paragraphs[paragraph_ix]))) .filter(|paragraph| paragraph.runs.iter().any(|run| !run.text.is_empty())) .collect() } -fn input_paragraph_from_document_range(document: &Document, paragraph_ix: usize, range: std::ops::Range) -> InputParagraph { +fn input_paragraph_from_document_range(document: &DocumentProjection, paragraph_ix: usize, range: std::ops::Range) -> InputParagraph { let paragraph = &document.paragraphs[paragraph_ix]; let paragraph_range = paragraph_byte_range(document, paragraph_ix); let start = range.start.min(paragraph_text_len(paragraph)); @@ -1042,31 +1094,6 @@ fn input_paragraph_from_document_range(document: &Document, paragraph_ix: usize, } } -fn paragraph_text_trimmed(document: &Document, paragraph_ix: usize) -> String { - document_text_slice(document, paragraph_byte_range(document, paragraph_ix)) - .trim() - .to_owned() -} - -fn cite_for_range(document: &Document, start: usize, end: usize) -> Option { - for paragraph_ix in start..end { - let paragraph = document.paragraphs.get(paragraph_ix)?; - let paragraph_range = paragraph_byte_range(document, paragraph_ix); - let mut offset = 0_usize; - for run in ¶graph.runs { - if run.styles.semantic == flowstate_document::SEMANTIC_CITE { - let text = document_text_slice(document, paragraph_range.start + offset..paragraph_range.start + offset + run.len); - let text = text.trim(); - if !text.is_empty() { - return Some(text.to_owned()); - } - } - offset += run.len; - } - } - None -} - fn first_non_empty_line(text: &str) -> Option { text .lines() @@ -1162,8 +1189,30 @@ fn modified_ns(metadata: &fs::Metadata) -> u64 { .expect("nanosecond timestamp is clamped to u64::MAX") } -fn fingerprint(size_bytes: u64, modified_ns: u64) -> String { - format!("{size_bytes}:{modified_ns}") +fn fingerprint(size_bytes: u64, modified_ns: u64, kind: FileKind, path: &Path) -> Result { + let mut fingerprint = format!("{size_bytes}:{modified_ns}"); + if kind == FileKind::Db8 + && let Some((frontier, unit_count)) = cached_search_metadata(path)? + { + fingerprint.push(':'); + fingerprint.push_str(&frontier); + fingerprint.push(':'); + fingerprint.push_str(&unit_count.to_string()); + } + Ok(fingerprint) +} + +fn cached_search_metadata(path: &Path) -> Result> { + let Some(units) = + DocumentPackage::read_cached_search_units(path).with_context(|| format!("reading cached Flowstate search units {}", path.display()))? + else { + return Ok(None); + }; + let frontier = units + .first() + .map(|unit| hex_bytes(&unit.frontier)) + .unwrap_or_default(); + Ok(Some((frontier, units.len()))) } fn stable_file_id(root: &Path, path: &Path) -> String { @@ -1266,3 +1315,15 @@ fn emit_tree_dir(relative_dir: &Path, depth: usize, context: &mut TreeEmitContex } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_search_unit_kind_is_preserved() { + let kind = SearchUnitKind::from_str("mystery_kind").expect("unknown kind should be preserved"); + assert_eq!(kind.as_str(), "mystery_kind"); + assert!(matches!(kind, SearchUnitKind::Unknown(value) if value == "mystery_kind")); + } +} diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index 7368610c..ece7eff7 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -10,17 +10,20 @@ workspace = true [dependencies] anyhow = { workspace = true } +async-channel = { workspace = true } clap = { workspace = true } dirs = { workspace = true } fff-search = { workspace = true } flowstate-document = { workspace = true } flowstate-docx = { workspace = true } +flowstate-collab = { workspace = true } flowstate-flow = { workspace = true } flowstate-tub = { workspace = true } gpui = { workspace = true } gpui-component = { workspace = true } gpui-component-assets = { workspace = true } hotpath = { workspace = true } +loro = { workspace = true } memchr = "2.8.1" mimalloc = { workspace = true } rustc-hash = { workspace = true } @@ -29,6 +32,11 @@ serde_json = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } uuid = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } +atomicwrites = "0.4.4" +blake3 = "1.8.5" [features] hotpath = [ diff --git a/crates/flowstate/src/app.rs b/crates/flowstate/src/app.rs index 32b58681..df1b2d60 100644 --- a/crates/flowstate/src/app.rs +++ b/crates/flowstate/src/app.rs @@ -17,7 +17,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt a use crate::app_settings::{load_app_settings, load_keymap}; use crate::commands::register_keymap; use crate::rich_text_element::{ - Document, DocumentExportAdapter, DocumentExportFormat, RichTextEditor, demo_document, set_document_export_adapter, write_db8, + DocumentExportAdapter, DocumentExportFormat, DocumentProjection, RichTextEditor, demo_document, set_document_export_adapter, }; use crate::workspace::open_workspace_window; @@ -38,7 +38,7 @@ pub struct RichTextEditorView { #[hotpath::measure_all] impl RichTextEditorView { /// Create a new editor entity from a loaded document. - pub fn new(document: Document, document_path: Option, cx: &mut Context) -> Self { + pub fn new(document: DocumentProjection, document_path: Option, cx: &mut Context) -> Self { let editor = cx.new(|cx| RichTextEditor::new_with_path(document, document_path, cx)); Self { editor } } @@ -286,13 +286,15 @@ impl Focusable for FlowPromptRenderer { /// can call the same maintenance path as the standalone binary. #[hotpath::measure] pub fn write_demo_document() -> anyhow::Result<()> { - write_db8("data/demo.db8", &demo_document())?; + let document = demo_document(); + let mut runtime = flowstate_collab::crdt_runtime::CrdtRuntime::from_document_projection(&document, "Flowstate Demo")?; + let _ = runtime.checkpoint_package("Flowstate Demo", Some("data/demo.db8".into()))?; Ok(()) } -struct FlowstateDocumentExportAdapter; +struct FlowstateFlowtextAdapter; -impl DocumentExportAdapter for FlowstateDocumentExportAdapter { +impl DocumentExportAdapter for FlowstateFlowtextAdapter { fn send_output_directory(&self, source_path: Option<&Path>, recovery_path: Option<&Path>) -> Option { if crate::app_settings::load_send_to_document_directory() { source_path @@ -304,19 +306,21 @@ impl DocumentExportAdapter for FlowstateDocumentExportAdapter { } } - fn write_document_export(&self, output_path: &Path, document: &Document, format: DocumentExportFormat) -> io::Result<()> { + fn write_document_export(&self, output_path: &Path, document: &DocumentProjection, format: DocumentExportFormat) -> io::Result<()> { match format { - DocumentExportFormat::Native => write_db8(output_path, document), - DocumentExportFormat::NativeWithExtension(_) => write_db8(output_path, document), + DocumentExportFormat::Native | DocumentExportFormat::NativeWithExtension(_) | DocumentExportFormat::Pdf => Err(io::Error::new( + io::ErrorKind::Unsupported, + "native Flowstate and PDF source export require the document CRDT runtime", + )), DocumentExportFormat::Docx => crate::docx_conversion::write_docx(output_path, document), - DocumentExportFormat::Pdf => crate::docx_conversion::write_pdf(output_path, document), } } } #[hotpath::measure] fn install_flowtext_adapters() { - let _ = set_document_export_adapter(Arc::new(FlowstateDocumentExportAdapter)); + let adapter = Arc::new(FlowstateFlowtextAdapter); + let _ = set_document_export_adapter(adapter); } /// Run the rich text processor by itself for focused component development. diff --git a/crates/flowstate/src/app_settings/io.rs b/crates/flowstate/src/app_settings/io.rs index a5b36eb0..2e20f077 100644 --- a/crates/flowstate/src/app_settings/io.rs +++ b/crates/flowstate/src/app_settings/io.rs @@ -66,6 +66,39 @@ pub fn load_keymap() -> crate::commands::Keymap { } } +/// §15/§31: load the stable per-install durable author identity, minting and +/// persisting a fresh one the first time it is requested. +/// +/// The returned `(user_id, display_name)` is bound to a live document runtime +/// via `CrdtRuntimeHandle::set_author_identity` so revisions record their +/// author and `users_by_id` is populated. Persisting is best-effort: a write +/// failure is logged but never fatal (the id regenerates on the next launch). +pub fn load_local_user_identity() -> (u128, Option) { + let mut settings = load_app_settings(); + if settings.local_user_id != 0 { + return (settings.local_user_id, settings.local_user_display_name); + } + + let user_id = uuid::Uuid::new_v4().as_u128(); + let display_name = os_username(); + settings.local_user_id = user_id; + settings.local_user_display_name = display_name.clone(); + if let Err(error) = save_app_settings(settings) { + tracing::warn!(error = %error, "persisting generated local user identity failed"); + } + (user_id, display_name) +} + +/// Best-effort OS account name used as the default author display name. Uses +/// only the standard environment so no extra dependency is introduced. +fn os_username() -> Option { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .ok() + .map(|name| name.trim().to_owned()) + .filter(|name| !name.is_empty()) +} + // Document style appearance is intentionally user-side. The DB8 file keeps // semantic assignments only; this app setting decides how those semantics look. #[hotpath::measure] diff --git a/crates/flowstate/src/app_settings/schema.rs b/crates/flowstate/src/app_settings/schema.rs index cc06a46d..60148521 100644 --- a/crates/flowstate/src/app_settings/schema.rs +++ b/crates/flowstate/src/app_settings/schema.rs @@ -14,6 +14,14 @@ use dirs::{config_dir, data_dir}; #[serde(default)] pub struct AppSettings { pub theme_name: Option, + // §15/§31: a stable per-install durable author identity. Serialized as a + // string because a v4 UUID rendered as `u128` overflows TOML's signed 64-bit + // integer range. A value of `0` means "not generated yet"; + // `load_local_user_identity` mints one and persists it on first read. Kept + // ahead of the table-valued fields below so TOML serialization stays valid. + #[serde(with = "local_user_id_serde")] + pub local_user_id: u128, + pub local_user_display_name: Option, pub document_theme: Option, pub editor: EditorSettings, pub toolkit: ToolkitSettings, @@ -21,6 +29,27 @@ pub struct AppSettings { pub keymap: Vec, } +/// Serde adapter that stores `local_user_id` as a string. TOML integers are +/// signed 64-bit, so a 128-bit identity cannot round-trip as a native integer. +mod local_user_id_serde { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &u128, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + raw.parse::().map_err(serde::de::Error::custom) + } +} + #[derive(Clone, Deserialize, Serialize)] #[serde(default)] pub struct EditorSettings { diff --git a/crates/flowstate/src/collab/asset_transfer.rs b/crates/flowstate/src/collab/asset_transfer.rs new file mode 100644 index 00000000..1eee4813 --- /dev/null +++ b/crates/flowstate/src/collab/asset_transfer.rs @@ -0,0 +1,198 @@ +use std::sync::Arc; + +use anyhow::{Result, ensure}; +use flowstate_collab::{crdt_runtime::RuntimeAssetMetadata, net::NetCommand}; +use gpui::{Context, SharedString}; + +use crate::rich_text_element::{AssetId, AssetRecord}; + +use super::CollabSession; + +pub(super) fn schedule_missing_assets( + session: &mut CollabSession, + preferred_peer: Option, + cx: &mut Context, +) { + let Some(editor) = session.editor.clone() else { + tracing::trace!(session = %session.session, preferred_peer = ?preferred_peer, "skipping collaboration asset scan because editor is missing"); + return; + }; + let Some(runtime) = session.runtime.clone() else { + tracing::trace!(session = %session.session, preferred_peer = ?preferred_peer, "skipping collaboration asset scan because Loro doc is missing"); + return; + }; + let session_id = session.session; + cx.spawn(async move |session, cx| { + let result = runtime.asset_metadata().await; + let _ = session.update(cx, |session, cx| match result { + Ok(assets) => schedule_missing_assets_from_metadata(session, editor, preferred_peer, assets, cx), + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "collaboration asset scan failed"); + }, + }); + }) + .detach(); +} + +fn schedule_missing_assets_from_metadata( + session: &mut CollabSession, + editor: gpui::Entity, + preferred_peer: Option, + assets: Vec, + cx: &mut Context, +) { + let assets = assets + .into_iter() + .map(ImageAssetMeta::from) + .collect::>(); + tracing::trace!(session = %session.session, assets = assets.len(), "scanned collaboration image assets"); + if assets.is_empty() { + return; + } + + let missing_assets = { + let editor = editor.read(cx); + assets + .iter() + .filter(|meta| { + let id = AssetId(meta.asset_id); + editor + .document() + .assets + .assets + .get(&id) + .is_none_or(AssetRecord::is_loading_placeholder) + }) + .cloned() + .collect::>() + }; + tracing::debug!(session = %session.session, assets = assets.len(), missing_assets = missing_assets.len(), "checked collaboration image assets for missing bytes"); + if missing_assets.is_empty() { + return; + } + + let placeholders = missing_assets + .iter() + .map(|meta| (AssetId(meta.asset_id), meta.placeholder_record())) + .collect::>(); + tracing::debug!(session = %session.session, placeholders = placeholders.len(), "queueing collaboration asset placeholder records"); + session.queue_asset_records(placeholders, cx); + + let candidates = session.pull_candidates(preferred_peer); + if candidates.is_empty() { + tracing::warn!(session = %session.session, missing_assets = missing_assets.len(), "cannot pull collaboration assets because no candidate peers are available"); + return; + } + + for meta in missing_assets { + let id = AssetId(meta.asset_id); + if !session.asset_pulls_in_flight.insert(id) { + tracing::trace!(session = %session.session, ?id, "collaboration asset pull already in flight"); + continue; + } + tracing::debug!(session = %session.session, ?id, bytes = meta.byte_len, candidate_count = candidates.len(), "scheduling collaboration asset pull"); + start_asset_pull(session, candidates.clone(), meta, cx); + } +} + +fn start_asset_pull( + session: &mut CollabSession, + candidates: Vec, + meta: ImageAssetMeta, + cx: &mut Context, +) { + let (reply_tx, reply_rx) = async_channel::bounded(1); + let id = AssetId(meta.asset_id); + let session_id = session.session; + let candidate_count = candidates.len(); + if let Err(error) = session.net_tx.try_send(NetCommand::PullAsset { + session: session.session, + candidates, + asset: meta.asset_id, + reply: reply_tx, + }) { + tracing::warn!(session = %session_id, ?id, candidate_count, error = %error, "queueing collaboration asset pull failed"); + session.asset_pulls_in_flight.remove(&id); + return; + } + tracing::debug!(session = %session_id, ?id, candidate_count, expected_bytes = meta.byte_len, "queued collaboration asset pull"); + + cx.spawn(async move |session, cx| { + let result = reply_rx.recv().await; + let _ = session.update(cx, |session, cx| { + session.asset_pulls_in_flight.remove(&id); + match result { + Ok(Ok(bytes)) => match meta.record_from_bytes(bytes.bytes) { + Ok(record) => { + tracing::debug!(session = %session_id, ?id, bytes = record.bytes.len(), "collaboration asset pull succeeded"); + session.queue_asset_records(vec![(id, record)], cx); + }, + Err(error) => tracing::warn!(session = %session_id, ?id, error = %format_args!("{error:#}"), "rejected fetched collaboration asset"), + }, + Ok(Err(error)) => tracing::warn!(session = %session_id, ?id, error = %format_args!("{error:#}"), "collaboration asset pull failed"), + Err(error) => tracing::warn!(session = %session_id, ?id, error = %error, "collaboration asset pull channel closed"), + } + }); + }) + .detach(); +} + +#[derive(Clone, Debug)] +struct ImageAssetMeta { + asset_id: u128, + mime: String, + original_name: Option, + content_hash: [u8; 32], + byte_len: u64, +} + +impl From for ImageAssetMeta { + fn from(value: RuntimeAssetMetadata) -> Self { + Self { + asset_id: value.asset_id, + mime: value.mime_type, + original_name: value.original_name, + content_hash: value.content_hash, + byte_len: value.byte_length, + } + } +} + +impl ImageAssetMeta { + fn placeholder_record(&self) -> AssetRecord { + AssetRecord { + id: AssetId(self.asset_id), + mime_type: SharedString::from(self.mime.clone()), + original_name: self.original_name.clone().map(SharedString::from), + content_hash: local_cache_hash(&self.content_hash), + bytes: Arc::new(Vec::new()), + } + } + + fn record_from_bytes(&self, bytes: Vec) -> Result { + tracing::trace!( + asset = self.asset_id, + expected_bytes = self.byte_len, + received_bytes = bytes.len(), + "validating fetched collaboration asset bytes" + ); + ensure!(bytes.len() as u64 == self.byte_len, "asset byte length mismatch"); + ensure!(blake3::hash(&bytes).as_bytes() == &self.content_hash, "asset BLAKE3 digest mismatch"); + let content_hash = local_cache_hash(&self.content_hash); + Ok(AssetRecord { + id: AssetId(self.asset_id), + mime_type: SharedString::from(self.mime.clone()), + original_name: self.original_name.clone().map(SharedString::from), + content_hash, + bytes: Arc::new(bytes), + }) + } +} + +fn local_cache_hash(content_hash: &[u8; 32]) -> u64 { + u64::from_le_bytes( + content_hash[..8] + .try_into() + .expect("BLAKE3 prefix is exactly eight bytes"), + ) +} diff --git a/crates/flowstate/src/collab/manager.rs b/crates/flowstate/src/collab/manager.rs new file mode 100644 index 00000000..54cf4543 --- /dev/null +++ b/crates/flowstate/src/collab/manager.rs @@ -0,0 +1,435 @@ +use std::collections::HashMap; + +use anyhow::{Context as _, Result, anyhow, ensure}; +use async_channel::Receiver; +use flowstate_collab::{ + SessionId, + crdt_runtime_actor::CrdtRuntimeHandle, + ids::PeerId, + net::{ + NetCommand, TicketSeed, + runtime::{self, CommandSender}, + }, + ticket::SessionTicket, +}; +use gpui::{App, AppContext, BorrowAppContext, Context, Entity, Global}; +use uuid::Uuid; + +use crate::rich_text_element::RichTextEditor; + +use super::session::{CollabSession, DetachReason, JoinedDocument, SessionPhase, SessionRosterEntry}; + +pub struct JoinRequest { + pub session: SessionId, + pub completed: Receiver>, +} + +#[derive(Default)] +pub struct CollabManager { + runtime: Option, + pub(super) sessions_by_id: HashMap>, + session_by_panel: HashMap, + own_endpoint_id: Option, + pub(super) endpoint_online: bool, + pub(super) event_pump_started: bool, +} + +impl Global for CollabManager {} + +#[derive(Clone)] +struct CollabRuntime { + commands: CommandSender, +} + +impl CollabManager { + pub fn init(cx: &mut C) + where + C: BorrowAppContext, + { + cx.update_default_global::(|_, _| {}); + } + + pub(super) fn shutdown_runtime(&mut self) { + tracing::info!( + open_sessions = self.sessions_by_id.len(), + runtime_running = self.runtime.is_some(), + "shutting down collaboration runtime" + ); + if let Some(runtime) = self.runtime.take() + && let Err(error) = runtime.commands.try_send(NetCommand::Shutdown) + { + tracing::warn!(error = %error, "queueing collaboration runtime shutdown failed"); + } + self.sessions_by_id.clear(); + self.session_by_panel.clear(); + self.own_endpoint_id = None; + self.event_pump_started = false; + self.endpoint_online = false; + tracing::debug!("collaboration runtime shutdown state cleared"); + } + + pub fn start_session_for_panel( + &mut self, + panel_id: Uuid, + editor: Entity, + title: String, + document_runtime: CrdtRuntimeHandle, + cx: &mut Context, + ) -> Result + where + T: 'static, + { + if let Some(session) = self.session_by_panel.get(&panel_id).copied() { + tracing::debug!(%panel_id, %session, "collaboration session already exists for panel"); + return Ok(session); + } + + let commands = self.ensure_runtime(cx)?; + let session = SessionId::new(); + tracing::info!( + %panel_id, + %session, + title = %title, + "starting local collaboration session", + ); + let collab = CollabSession::from_local_runtime(session, panel_id, editor, title, document_runtime, commands.clone()); + let direct_handler = collab.direct_handler(); + let entity = cx.new(|_| collab); + entity.update(cx, |session, cx| session.attach(cx)); + + self.register_session(entity.clone(), cx); + if let Err(error) = commands.try_send(NetCommand::RegisterDirectHandler { + session, + handler: direct_handler, + }) { + tracing::error!(%panel_id, %session, error = %error, "registering collaboration direct handler failed"); + Self::detach_entity( + &entity, + DetachReason::Fatal(format!("registering collaboration direct handler failed: {error}")), + cx, + ); + self.unregister_session(session); + return Err(error).context("registering collaboration direct handler failed"); + } + tracing::debug!(%panel_id, %session, "registered collaboration direct handler for local session"); + + let (reply_tx, reply_rx) = async_channel::bounded(1); + if let Err(error) = commands.try_send(NetCommand::CreateSession { session, reply: reply_tx }) { + tracing::error!(%panel_id, %session, error = %error, "queueing collaboration create-session command failed"); + Self::detach_entity( + &entity, + DetachReason::Fatal(format!("creating collaboration network session failed: {error}")), + cx, + ); + self.unregister_session(session); + return Err(error).context("creating collaboration network session failed"); + } + tracing::debug!(%panel_id, %session, "queued collaboration create-session command"); + Self::finish_create_session(entity, reply_rx, cx); + Ok(session) + } + + pub fn join_session(&mut self, ticket: SessionTicket, cx: &mut Context) -> Result + where + T: 'static, + { + tracing::info!(session = %ticket.session, inviter = %ticket.inviter.id, title = %ticket.title, "joining collaboration session from ticket"); + if !ticket.is_supported_version() { + tracing::warn!(session = %ticket.session, "unsupported collaboration protocol version in ticket"); + } + ensure!(ticket.is_supported_version(), "unsupported collaboration protocol version"); + let commands = self.ensure_runtime(cx)?; + commands + .try_send(NetCommand::EnsureUp) + .context("starting collaboration networking failed")?; + ensure!( + self.own_endpoint_id != Some(ticket.inviter.id), + "That's your own invite — open the Share dialog instead." + ); + if self.unregister_detached_session(ticket.session, cx) { + tracing::info!(session = %ticket.session, "discarded detached collaboration session before rejoin"); + } + if let Some(existing) = self.sessions_by_id.get(&ticket.session) { + let phase = existing.read(cx).phase().clone(); + tracing::warn!(session = %ticket.session, ?phase, "collaboration session is already open"); + return Err(anyhow!("collaboration session is already open")); + } + + let session = ticket.session; + let inviter = ticket.inviter.id; + let collab = CollabSession::joining(session, ticket.title.clone(), commands.clone(), vec![ticket.inviter.clone()]); + let entity = cx.new(|_| collab); + self.register_session(entity.clone(), cx); + let neighbor_rx = entity.update(cx, |session, cx| session.prepare_join_neighbor_wait(cx)); + + if let Err(error) = commands.try_send(NetCommand::JoinSession { + session, + bootstrap: vec![ticket.inviter], + }) { + tracing::error!(%session, error = %error, "queueing collaboration join-session command failed"); + entity.update(cx, |session, cx| { + session.detach( + DetachReason::JoinFailed(format!("joining collaboration network session failed: {error}")), + cx, + ); + }); + self.unregister_session(session); + return Err(error).context("joining collaboration network session failed"); + } + tracing::debug!(%session, inviter = %inviter, "queued collaboration join-session command"); + let completed = entity.update(cx, |session, cx| session.begin_join_bootstrap(inviter, neighbor_rx, cx)); + Ok(JoinRequest { session, completed }) + } + + pub fn attach_joined_session( + &mut self, + session_id: SessionId, + panel_id: Uuid, + editor: Entity, + cx: &mut Context, + ) -> Result<()> + where + T: 'static, + { + tracing::info!(%session_id, %panel_id, "attaching joined collaboration session to editor"); + let commands = self.ensure_runtime(cx)?; + let session = self + .sessions_by_id + .get(&session_id) + .cloned() + .ok_or_else(|| anyhow!("collaboration session is not registered"))?; + let direct_handler = session.read(cx).direct_handler(); + if let Err(error) = commands.try_send(NetCommand::RegisterDirectHandler { + session: session_id, + handler: direct_handler, + }) { + tracing::error!(%session_id, %panel_id, error = %error, "registering collaboration direct handler for joined session failed"); + Self::detach_entity( + &session, + DetachReason::Fatal(format!("registering collaboration direct handler failed: {error}")), + cx, + ); + self.unregister_session(session_id); + return Err(error).context("registering collaboration direct handler failed"); + } + tracing::debug!(%session_id, %panel_id, "registered collaboration direct handler for joined session"); + if let Err(error) = Self::establish_joined_peer(session.clone(), commands, cx) { + Self::detach_entity( + &session, + DetachReason::Fatal(format!("collaboration endpoint address unavailable: {error:#}")), + cx, + ); + self.unregister_session(session_id); + return Err(error); + } + if let Err(error) = session.update(cx, |session, cx| session.attach_joined_editor(panel_id, editor, cx)) { + Self::detach_entity( + &session, + DetachReason::Fatal(format!("attaching joined collaboration editor failed: {error:#}")), + cx, + ); + self.unregister_session(session_id); + return Err(error); + } + self.session_by_panel.insert(panel_id, session_id); + Ok(()) + } + + pub fn leave_session_for_panel(&mut self, panel_id: Uuid, cx: &mut Context) -> bool + where + T: 'static, + { + let Some(session_id) = self.session_by_panel.get(&panel_id).copied() else { + tracing::debug!(%panel_id, "leave requested for panel without collaboration session"); + return false; + }; + let Some(session) = self.sessions_by_id.get(&session_id).cloned() else { + tracing::warn!(%panel_id, %session_id, "leave requested for missing registered collaboration session"); + self.unregister_session(session_id); + return false; + }; + + tracing::info!(%panel_id, %session_id, "leaving collaboration session for panel"); + let _ = session.update(cx, |session, cx| session.detach(DetachReason::UserLeft, cx)); + self.unregister_session(session_id); + true + } + + pub fn request_ticket_for_panel(&mut self, panel_id: Uuid, cx: &mut Context) -> Option>> + where + T: 'static, + { + let session_id = self.session_by_panel.get(&panel_id).copied()?; + let session = self.sessions_by_id.get(&session_id)?.clone(); + let title = session.read(cx).title().to_string(); + let commands = self.ensure_runtime(cx).ok()?; + let (ticket_tx, ticket_rx) = async_channel::bounded(1); + let (reply_tx, reply_rx) = async_channel::bounded(1); + if let Err(error) = commands.try_send(NetCommand::MintTicketAddr { reply: reply_tx }) { + tracing::error!(%panel_id, %session_id, error = %error, "queueing collaboration ticket address command failed"); + return None; + } + tracing::debug!(%panel_id, %session_id, "requested collaboration ticket address"); + + cx.spawn(async move |_, cx| { + let ticket = match reply_rx.recv().await { + Ok(addr) => { + tracing::info!(%session_id, inviter = %addr.id, "created collaboration share ticket"); + let own_endpoint_id = addr.id; + let _ = cx.update_global::(|manager, _| manager.own_endpoint_id = Some(own_endpoint_id)); + Ok(SessionTicket::new(session_id, addr, title)) + }, + Err(error) => { + tracing::error!(%session_id, error = %error, "collaboration endpoint address unavailable for ticket"); + Err(anyhow!("collaboration endpoint address unavailable: {error}")) + }, + }; + let _ = ticket_tx.send(ticket).await; + }) + .detach(); + Some(ticket_rx) + } + + pub fn session_for_panel(&self, panel_id: Uuid) -> Option> { + self + .session_by_panel + .get(&panel_id) + .and_then(|session| self.sessions_by_id.get(session)) + .cloned() + } + + pub fn phase_for_panel(&self, panel_id: Uuid, cx: &App) -> Option { + self + .session_for_panel(panel_id) + .map(|session| session.read(cx).phase().clone()) + } + + pub fn phase_for_session(&self, session_id: SessionId, cx: &App) -> Option { + self + .sessions_by_id + .get(&session_id) + .map(|session| session.read(cx).phase().clone()) + } + + pub fn session_for_id(&self, session_id: SessionId) -> Option> { + self.sessions_by_id.get(&session_id).cloned() + } + + pub fn roster_for_panel(&self, panel_id: Uuid, cx: &App) -> Vec { + self + .session_for_panel(panel_id) + .map_or_else(Vec::new, |session| session.read(cx).roster()) + } + + fn register_session(&mut self, session: Entity, cx: &mut Context) { + let id = session.read(cx).session_id(); + if let Some(panel_id) = session.read(cx).panel_id() { + self.session_by_panel.insert(panel_id, id); + tracing::debug!(%id, %panel_id, "registered collaboration session panel mapping"); + } + self.sessions_by_id.insert(id, session); + tracing::debug!(%id, open_sessions = self.sessions_by_id.len(), "registered collaboration session"); + } + + pub(super) fn unregister_session(&mut self, session: SessionId) { + let removed = self.sessions_by_id.remove(&session).is_some(); + self.session_by_panel.retain(|_, active| *active != session); + tracing::debug!(%session, removed, open_sessions = self.sessions_by_id.len(), "unregistered collaboration session"); + } + + fn unregister_detached_session(&mut self, session: SessionId, cx: &mut Context) -> bool + where + T: 'static, + { + let detached = self + .sessions_by_id + .get(&session) + .is_some_and(|existing| matches!(existing.read(cx).phase(), SessionPhase::Detached(_))); + if detached { + self.unregister_session(session); + } + detached + } + + fn ensure_runtime(&mut self, cx: &mut Context) -> Result + where + T: 'static, + { + if let Some(runtime) = &self.runtime { + tracing::trace!("collaboration runtime already available"); + return Ok(runtime.commands.clone()); + } + + tracing::info!("starting collaboration runtime for manager"); + let (commands, events) = runtime::start()?; + self.runtime = Some(CollabRuntime { commands: commands.clone() }); + self.start_event_pump(events, cx); + Ok(commands) + } + + fn finish_create_session(session: Entity, reply_rx: Receiver>, cx: &mut Context) + where + T: 'static, + { + let session_id = session.read(cx).session_id(); + cx.spawn(async move |_, cx| match reply_rx.recv().await { + Ok(Ok(seed)) => { + tracing::info!(%session_id, inviter = %seed.inviter.id, "collaboration create-session completed"); + let own_endpoint_id = seed.inviter.id; + let _ = cx.update_global::(|manager, _| manager.own_endpoint_id = Some(own_endpoint_id)); + let _ = session.update(cx, |session, cx| session.establish_local_peer(&seed.inviter.id, cx)); + }, + Ok(Err(error)) => { + tracing::error!(%session_id, error = %format_args!("{error:#}"), "collaboration create-session failed"); + let _ = session.update(cx, |session, cx| { + session.detach(DetachReason::Fatal(format!("creating collaboration session failed: {error:#}")), cx); + }); + }, + Err(error) => { + tracing::error!(%session_id, error = %error, "collaboration create-session reply channel closed"); + let _ = session.update(cx, |session, cx| { + session.detach(DetachReason::Fatal(format!("creating collaboration session failed: {error}")), cx); + }); + }, + }) + .detach(); + } + + fn establish_joined_peer(session: Entity, commands: CommandSender, cx: &mut Context) -> Result<()> + where + T: 'static, + { + let session_id = session.read(cx).session_id(); + let (reply_tx, reply_rx) = async_channel::bounded(1); + if let Err(error) = commands.try_send(NetCommand::MintTicketAddr { reply: reply_tx }) { + tracing::error!(%session_id, error = %error, "queueing collaboration endpoint address command failed"); + return Err(error).context("requesting collaboration endpoint address failed"); + } + tracing::debug!(%session_id, "requested collaboration endpoint address for joined session"); + cx.spawn(async move |_, cx| match reply_rx.recv().await { + Ok(addr) => { + tracing::info!(%session_id, peer = %addr.id, "collaboration joined session local peer established"); + let own_endpoint_id = addr.id; + let _ = cx.update_global::(|manager, _| manager.own_endpoint_id = Some(own_endpoint_id)); + let _ = session.update(cx, |session, cx| session.establish_local_peer(&addr.id, cx)); + }, + Err(error) => { + tracing::error!(%session_id, error = %error, "collaboration endpoint address unavailable for joined session"); + let _ = session.update(cx, |session, cx| { + session.detach(DetachReason::Fatal(format!("collaboration endpoint address unavailable: {error}")), cx); + }); + }, + }) + .detach(); + Ok(()) + } + + fn detach_entity(entity: &Entity, reason: DetachReason, cx: &mut Context) + where + T: 'static, + { + entity.update(cx, |session, cx| { + session.detach(reason, cx); + }); + } +} diff --git a/crates/flowstate/src/collab/mod.rs b/crates/flowstate/src/collab/mod.rs new file mode 100644 index 00000000..dfaa7668 --- /dev/null +++ b/crates/flowstate/src/collab/mod.rs @@ -0,0 +1,117 @@ +mod manager; +pub(crate) mod notify; +mod presence_view; +mod pump; +mod session; +pub mod share_dialog; +mod shutdown; +pub mod status; + +use anyhow::Result; +use async_channel::Receiver; +use flowstate_collab::{SessionId, crdt_runtime_actor::CrdtRuntimeHandle, ticket::SessionTicket}; +use gpui::{App, BorrowAppContext, Context, Entity, ReadGlobal}; +use uuid::Uuid; + +use crate::rich_text_element::RichTextEditor; + +pub use manager::{CollabManager, JoinRequest}; +pub use session::{ + Attachment, CollabSession, Connectivity, DetachReason, JoinStage, JoinedDocument, SessionNotice, SessionPhase, SessionRosterEntry, +}; +pub use shutdown::shutdown; + +pub fn init(cx: &mut C) +where + C: BorrowAppContext, +{ + CollabManager::init(cx); +} + +pub fn start_session_for_panel( + panel_id: Uuid, + editor: Entity, + title: String, + runtime: CrdtRuntimeHandle, + cx: &mut Context, +) -> Result +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.start_session_for_panel(panel_id, editor, title, runtime, cx)) +} + +pub fn runtime_for_session(session_id: SessionId, cx: &App) -> Option { + CollabManager::global(cx) + .session_for_id(session_id) + .and_then(|session| session.read(cx).runtime_handle()) +} + +pub fn join_session(ticket: SessionTicket, cx: &mut Context) -> Result +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.join_session(ticket, cx)) +} + +pub fn attach_joined_session(session_id: SessionId, panel_id: Uuid, editor: Entity, cx: &mut Context) -> Result<()> +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.attach_joined_session(session_id, panel_id, editor, cx)) +} + +pub fn leave_session_for_panel(panel_id: Uuid, cx: &mut Context) -> bool +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.leave_session_for_panel(panel_id, cx)) +} + +pub fn request_ticket_for_panel(panel_id: Uuid, cx: &mut Context) -> Option>> +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.request_ticket_for_panel(panel_id, cx)) +} + +pub fn phase_for_panel(panel_id: Uuid, cx: &App) -> Option { + CollabManager::global(cx).phase_for_panel(panel_id, cx) +} + +pub fn phase_for_session(session_id: SessionId, cx: &App) -> Option { + CollabManager::global(cx).phase_for_session(session_id, cx) +} + +pub fn session_for_panel(panel_id: Uuid, cx: &App) -> Option> { + CollabManager::global(cx).session_for_panel(panel_id) +} + +pub fn session_for_id(session_id: SessionId, cx: &App) -> Option> { + CollabManager::global(cx).session_for_id(session_id) +} + +pub fn roster_for_panel(panel_id: Uuid, cx: &App) -> Vec { + CollabManager::global(cx).roster_for_panel(panel_id, cx) +} + +/// Notify the collaboration session for `panel_id` that the document's runtime +/// was just checkpointed (saved) outside the session. +/// +/// A save records a named revision into Loro via the runtime's save hook, which +/// advances the canonical version vector but cannot route the resulting +/// `LocalUpdate` events back to the session (the save hook future has no GPUI +/// context). This refreshes the session's cached runtime version vector and +/// re-publishes an anti-entropy digest so peers converge on the new revision op +/// promptly. Returns `false` (a harmless no-op) when the panel is not part of an +/// active collaboration session, so solo-document saves are unaffected. +pub fn refresh_after_external_checkpoint(panel_id: Uuid, cx: &mut Context) -> bool +where + T: 'static, +{ + let Some(session) = session_for_panel(panel_id, cx) else { + return false; + }; + session.update(cx, |session, cx| session.refresh_after_external_checkpoint(cx)); + true +} diff --git a/crates/flowstate/src/collab/notify.rs b/crates/flowstate/src/collab/notify.rs new file mode 100644 index 00000000..da604276 --- /dev/null +++ b/crates/flowstate/src/collab/notify.rs @@ -0,0 +1,42 @@ +use gpui::{App, PromptButton, PromptLevel, Window}; +use gpui_component::{WindowExt as _, notification::Notification}; + +use super::SessionNotice; + +pub fn show_session_notice(notice: &SessionNotice, window: &mut Window, cx: &mut App) { + match notice { + SessionNotice::PeerJoined(name) => push_info(collaborator_message(name, "joined"), window, cx), + SessionNotice::PeerLeft(name) => push_info(collaborator_message(name, "left"), window, cx), + SessionNotice::LeftSession => push_info("Left session - this copy is now local.", window, cx), + SessionNotice::Disconnected(detail) => { + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Collaboration disconnected", + Some(detail.as_str()), + &[PromptButton::ok("Ok")], + cx, + )); + }, + SessionNotice::ViewRebuilt => { + #[cfg(debug_assertions)] + push_info("Collaboration view rebuilt from remote state.", window, cx); + }, + SessionNotice::IncompatibleVersion(peer) => push_warning(format!("{peer} is using an incompatible collaboration version."), window, cx), + } +} + +fn push_info(message: impl Into, window: &mut Window, cx: &mut App) { + window.push_notification(Notification::info(message).title("Collaboration"), cx); +} + +fn push_warning(message: impl Into, window: &mut Window, cx: &mut App) { + window.push_notification(Notification::warning(message).title("Collaboration"), cx); +} + +fn collaborator_message(name: &str, action: &str) -> String { + if name.trim().is_empty() { + format!("A collaborator {action}") + } else { + format!("{name} {action}") + } +} diff --git a/crates/flowstate/src/collab/presence_view.rs b/crates/flowstate/src/collab/presence_view.rs new file mode 100644 index 00000000..513a47a7 --- /dev/null +++ b/crates/flowstate/src/collab/presence_view.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; + +use flowstate_collab::SessionId; + +pub fn collaboration_recovery_path(session: SessionId, title: &str) -> PathBuf { + let dir = std::env::temp_dir().join("flowstate-collab-recovery"); + if let Err(error) = std::fs::create_dir_all(&dir) { + tracing::warn!("creating collaboration recovery directory failed ({}): {error}", dir.display()); + } + let session_hex = session.to_string(); + let prefix = session_hex.get(..16).unwrap_or(&session_hex); + dir.join(format!("{prefix}-{}.db8", sanitized_recovery_title(title))) +} + +fn sanitized_recovery_title(title: &str) -> String { + let mut out = String::new(); + for ch in title.chars() { + if out.len() >= 48 { + break; + } + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + out.push(ch); + } else if ch.is_whitespace() && !out.ends_with('_') { + out.push('_'); + } + } + let trimmed = out.trim_matches(['_', '.']).to_string(); + if trimmed.is_empty() { "shared-document".to_string() } else { trimmed } +} diff --git a/crates/flowstate/src/collab/pump.rs b/crates/flowstate/src/collab/pump.rs new file mode 100644 index 00000000..29dbaf14 --- /dev/null +++ b/crates/flowstate/src/collab/pump.rs @@ -0,0 +1,93 @@ +use flowstate_collab::{ + SessionId, + net::{NetEvent, runtime}, +}; +use gpui::{App, Context}; + +use super::{ + manager::CollabManager, + session::{CollabSession, DetachReason}, +}; + +impl CollabManager { + pub(super) fn start_event_pump(&mut self, events: runtime::EventReceiver, cx: &mut Context) + where + T: 'static, + { + if self.event_pump_started { + tracing::trace!("collaboration network event pump already started"); + return; + } + self.event_pump_started = true; + tracing::debug!("starting collaboration network event pump"); + cx.spawn(async move |_, cx| { + while let Ok(event) = events.recv().await { + if cx + .update_global::(|manager, cx| manager.handle_net_event(event, cx)) + .is_err() + { + tracing::warn!("collaboration network event pump could not update manager; stopping"); + break; + } + } + tracing::debug!("collaboration network event pump stopped"); + }) + .detach(); + } + + fn handle_net_event(&mut self, event: NetEvent, cx: &mut App) { + match event { + NetEvent::EndpointOnline(online) => { + tracing::info!(online, "collaboration endpoint status changed"); + self.endpoint_online = online; + for session in self.sessions_by_id.values() { + session.update(cx, |session, cx| session.set_endpoint_online(online, cx)); + } + }, + NetEvent::Gossip { session, from, msg } => { + tracing::trace!(%session, from = %from, gossip_kind = msg.kind(), gossip_payload_bytes = msg.payload_len(), "routing collaboration gossip event"); + self.update_session(session, cx, |session, cx| session.handle_gossip(from, msg, cx)); + }, + NetEvent::IncompatibleVersion { session, peer } => { + tracing::warn!(%session, peer = %peer, "routing collaboration incompatible-version event"); + self.update_session(session, cx, |session, cx| session.handle_incompatible_version(peer, cx)); + }, + NetEvent::NeighborUp { session, peer } => { + tracing::debug!(%session, peer = %peer, "routing collaboration neighbor-up event"); + self.update_session(session, cx, |session, cx| session.neighbor_up(peer, cx)); + }, + NetEvent::NeighborDown { session, peer } => { + tracing::debug!(%session, peer = %peer, "routing collaboration neighbor-down event"); + self.update_session(session, cx, |session, cx| session.neighbor_down(peer, cx)); + }, + NetEvent::GossipLagged { session } => { + tracing::warn!(%session, "routing collaboration gossip-lagged event"); + self.update_session(session, cx, |session, cx| session.handle_gossip_lagged(cx)); + }, + NetEvent::SubscribeFailed { session, error } => { + tracing::error!(%session, error = %error, "collaboration gossip subscription failed"); + let detached = self.update_session(session, cx, |session, cx| session.detach(DetachReason::Fatal(error), cx)); + if detached.unwrap_or(false) { + self.unregister_session(session); + } + }, + } + } + + fn update_session( + &mut self, + session: SessionId, + cx: &mut App, + update: impl FnOnce(&mut CollabSession, &mut Context) -> R, + ) -> Option { + self + .sessions_by_id + .get(&session) + .cloned() + .map(|session| session.update(cx, update)) + .or_else(|| { + tracing::warn!(%session, "collaboration event targeted missing session"); + None + }) + } +} diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs new file mode 100644 index 00000000..31f54fdd --- /dev/null +++ b/crates/flowstate/src/collab/session.rs @@ -0,0 +1,1247 @@ +use std::{ + collections::HashSet, + rc::Rc, + time::{Duration, Instant}, +}; + +use anyhow::{Context as _, Result, anyhow, bail}; +use flowstate_collab::{ + SessionId, + crdt_runtime::{CrdtRuntime, RuntimeEvent}, + crdt_runtime_actor::CrdtRuntimeHandle, + ids::PeerId, + net::{ + NetCommand, PeerAddr, PublishPayload, + anti_entropy::AntiEntropyState, + direct::{DirectServeRequest, DirectSessionHandler}, + runtime::CommandSender, + }, + presence::PresenceStore, + proto_gossip::GossipMsg, +}; +use gpui::{Context, Entity, EventEmitter, Subscription, Timer}; +use loro::{LoroDoc, Subscription as LoroSubscription}; +use uuid::Uuid; + +use crate::app_settings::{load_document_theme, load_local_user_identity}; +use crate::rich_text_element::{ + AssetId, AssetRecord, DocumentProjection, EditorEvent, ProjectionPatch, RichTextEditor, SemanticCommandBatch, SemanticEditCommand, + UndoRedirect, +}; + +use super::presence_view; + +#[path = "asset_transfer.rs"] +mod asset_transfer; +#[path = "session_io.rs"] +mod session_io; +#[path = "session_presence.rs"] +mod session_presence; +#[path = "session_timers.rs"] +mod session_timers; + +#[derive(Clone, Debug)] +pub enum SessionPhase { + Creating, + Joining(JoinStage), + Attached(Attachment), + Detached(DetachReason), +} + +#[derive(Clone, Debug)] +pub struct Attachment { + pub connectivity: Connectivity, + pub peers_present: usize, +} + +#[derive(Clone, Debug)] +pub enum Connectivity { + Online, + Offline { since: Instant, retries: u32 }, +} + +#[derive(Clone, Debug)] +pub enum JoinStage { + Resolving, + Subscribing, + FetchingSnapshot { got: u64, total: Option }, + Building, +} + +#[derive(Clone, Debug)] +pub enum DetachReason { + UserLeft, + JoinFailed(String), + Fatal(String), +} + +#[derive(Clone, Debug)] +pub enum SessionNotice { + PeerJoined(String), + PeerLeft(String), + LeftSession, + Disconnected(String), + ViewRebuilt, + IncompatibleVersion(String), +} + +impl EventEmitter for CollabSession {} + +pub(super) enum JoinNeighborSignal { + NeighborUp, + TimedOut, +} + +pub struct JoinedDocument { + pub session: SessionId, + pub title: String, + pub document: DocumentProjection, +} + +#[derive(Clone, Debug)] +pub struct SessionRosterEntry { + pub name: String, + pub color_rgb: u32, + pub is_self: bool, +} + +pub struct CollabSession { + session: SessionId, + title: String, + phase: SessionPhase, + runtime: Option, + runtime_vv: Vec, + editor: Option>, + panel_id: Option, + // UI-only asset cache records fetched after Loro metadata arrives. + pending_asset_records: Vec<(AssetId, AssetRecord)>, + pending_remote_updates: Vec>, + presence: Option, + net_tx: CommandSender, + direct_tx: async_channel::Sender, + direct_rx: async_channel::Receiver, + undo_tx: async_channel::Sender, + undo_rx: async_channel::Receiver, + editor_subscriptions: Vec, + loro_subscriptions: Vec, + neighbors: HashSet, + bootstrap_addrs: Vec, + asset_pulls_in_flight: HashSet, + anti_entropy: AntiEntropyState, + direct_pump_started: bool, + undo_pump_started: bool, + presence_refresh_pending: bool, + presence_refresh_generation: u64, + external_caret_refresh_pending: bool, + external_caret_refresh_generation: u64, + timers_started: bool, + endpoint_online: bool, + zero_neighbors_since: Option, + inbound_since_last_digest: bool, + quiet_digest_rounds: u8, + next_recovery_at: Option, + awaiting_recovery_neighbor_until: Option, + known_peers: HashSet, + probe_pending: bool, + last_probe_failed: bool, + join_neighbor_tx: Option>, + incompatible_version_peers: HashSet, + last_document_activity: Instant, + last_self_check: Option<(Vec, u64)>, + local_edit_flush_pending: bool, +} + +const DIRECT_REQUEST_CHANNEL_CAPACITY: usize = 32; +const UNDO_REQUEST_CHANNEL_CAPACITY: usize = 128; +const JOIN_FIRST_NEIGHBOR_TIMEOUT: Duration = Duration::from_secs(15); +const JOIN_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(30); +const LOCAL_EDIT_FLUSH_DEBOUNCE_MS: u64 = 24; + +fn coalesce_collaboration_commands(commands: impl IntoIterator) -> Vec { + let mut coalesced = Vec::new(); + for command in commands { + if let Some(previous) = coalesced.last_mut() + && merge_adjacent_insert_text(previous, &command) + { + continue; + } + coalesced.push(command); + } + coalesced +} + +fn merge_adjacent_insert_text(previous: &mut SemanticEditCommand, next: &SemanticEditCommand) -> bool { + let SemanticEditCommand::InsertText { at, text, styles } = previous else { + return false; + }; + let SemanticEditCommand::InsertText { + at: next_at, + text: next_text, + styles: next_styles, + } = next + else { + return false; + }; + if *styles != *next_styles || at.paragraph != next_at.paragraph || at.byte + text.len() != next_at.byte { + return false; + } + text.push_str(next_text); + true +} + +impl CollabSession { + pub fn from_local_runtime( + session: SessionId, + panel_id: Uuid, + editor: Entity, + title: String, + runtime: CrdtRuntimeHandle, + net_tx: CommandSender, + ) -> Self { + // Direct serving is network-bound and should backpressure rather than grow without limit. + let (direct_tx, direct_rx) = async_channel::bounded(DIRECT_REQUEST_CHANNEL_CAPACITY); + // Undo redirects can burst under key-repeat, but a stuck pump should not retain unbounded UI events. + let (undo_tx, undo_rx) = async_channel::bounded(UNDO_REQUEST_CHANNEL_CAPACITY); + let now = Instant::now(); + tracing::info!( + %session, + %panel_id, + title = %title, + "attached local collaboration session to document runtime", + ); + + Self { + session, + title, + phase: SessionPhase::Creating, + runtime: Some(runtime), + runtime_vv: Vec::new(), + editor: Some(editor), + panel_id: Some(panel_id), + pending_asset_records: Vec::new(), + pending_remote_updates: Vec::new(), + presence: None, + net_tx, + direct_tx, + direct_rx, + undo_tx, + undo_rx, + editor_subscriptions: Vec::new(), + loro_subscriptions: Vec::new(), + neighbors: HashSet::new(), + bootstrap_addrs: Vec::new(), + asset_pulls_in_flight: HashSet::new(), + anti_entropy: AntiEntropyState::new(session, now), + direct_pump_started: false, + undo_pump_started: false, + presence_refresh_pending: false, + presence_refresh_generation: 0, + external_caret_refresh_pending: false, + external_caret_refresh_generation: 0, + timers_started: false, + endpoint_online: true, + zero_neighbors_since: Some(now), + inbound_since_last_digest: false, + quiet_digest_rounds: 0, + next_recovery_at: None, + awaiting_recovery_neighbor_until: None, + known_peers: HashSet::new(), + probe_pending: false, + last_probe_failed: false, + join_neighbor_tx: None, + incompatible_version_peers: HashSet::new(), + last_document_activity: now, + last_self_check: None, + local_edit_flush_pending: false, + } + } + + pub fn joining(session: SessionId, title: String, net_tx: CommandSender, bootstrap_addrs: Vec) -> Self { + let (direct_tx, direct_rx) = async_channel::bounded(DIRECT_REQUEST_CHANNEL_CAPACITY); + let (undo_tx, undo_rx) = async_channel::bounded(UNDO_REQUEST_CHANNEL_CAPACITY); + let now = Instant::now(); + tracing::info!(%session, title = %title, bootstrap_count = bootstrap_addrs.len(), "created joining collaboration session state"); + Self { + session, + title, + phase: SessionPhase::Joining(JoinStage::Resolving), + runtime: None, + runtime_vv: Vec::new(), + editor: None, + panel_id: None, + pending_asset_records: Vec::new(), + pending_remote_updates: Vec::new(), + presence: None, + net_tx, + direct_tx, + direct_rx, + undo_tx, + undo_rx, + editor_subscriptions: Vec::new(), + loro_subscriptions: Vec::new(), + neighbors: HashSet::new(), + bootstrap_addrs, + asset_pulls_in_flight: HashSet::new(), + anti_entropy: AntiEntropyState::new(session, now), + direct_pump_started: false, + undo_pump_started: false, + presence_refresh_pending: false, + presence_refresh_generation: 0, + external_caret_refresh_pending: false, + external_caret_refresh_generation: 0, + timers_started: false, + endpoint_online: true, + zero_neighbors_since: Some(now), + inbound_since_last_digest: false, + quiet_digest_rounds: 0, + next_recovery_at: None, + awaiting_recovery_neighbor_until: None, + known_peers: HashSet::new(), + probe_pending: false, + last_probe_failed: false, + join_neighbor_tx: None, + incompatible_version_peers: HashSet::new(), + last_document_activity: now, + last_self_check: None, + local_edit_flush_pending: false, + } + } + + pub fn session_id(&self) -> SessionId { + self.session + } + + pub fn panel_id(&self) -> Option { + self.panel_id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn phase(&self) -> &SessionPhase { + &self.phase + } + + pub fn roster(&self) -> Vec { + let Some(presence) = &self.presence else { + return Vec::new(); + }; + let self_key = presence.self_key().to_string(); + presence + .roster() + .into_iter() + .map(|entry| SessionRosterEntry { + is_self: entry.key == self_key, + name: entry.name, + color_rgb: entry.color_rgb, + }) + .collect() + } + + pub fn direct_handler(&self) -> DirectSessionHandler { + DirectSessionHandler::new(self.direct_tx.clone()) + } + + pub(super) fn pull_candidates(&self, preferred: Option) -> Vec { + let mut candidates = Vec::with_capacity(self.neighbors.len() + usize::from(preferred.is_some())); + if let Some(peer) = preferred { + candidates.push(peer); + } + candidates.extend( + self + .neighbors + .iter() + .copied() + .filter(|peer| Some(*peer) != preferred), + ); + candidates + } + + pub fn set_join_stage(&mut self, stage: JoinStage, cx: &mut Context) { + tracing::debug!(session = %self.session, ?stage, "collaboration join stage changed"); + self.phase = SessionPhase::Joining(stage); + cx.notify(); + } + + pub(super) fn prepare_join_neighbor_wait(&mut self, cx: &mut Context) -> async_channel::Receiver { + let (neighbor_tx, neighbor_rx) = async_channel::bounded(1); + self.join_neighbor_tx = Some(neighbor_tx.clone()); + self.phase = SessionPhase::Joining(JoinStage::Subscribing); + cx.spawn(async move |_, _| { + Timer::after(JOIN_FIRST_NEIGHBOR_TIMEOUT).await; + let _ = neighbor_tx.try_send(JoinNeighborSignal::TimedOut); + }) + .detach(); + cx.notify(); + neighbor_rx + } + + pub(super) fn begin_join_bootstrap( + &mut self, + inviter: PeerId, + neighbor_rx: async_channel::Receiver, + cx: &mut Context, + ) -> async_channel::Receiver> { + let (result_tx, result_rx) = async_channel::bounded(1); + let session_id = self.session; + + cx.spawn(async move |session, cx| { + match neighbor_rx.recv().await { + Ok(JoinNeighborSignal::NeighborUp) => {}, + Ok(JoinNeighborSignal::TimedOut) | Err(_) => { + let detail = "Couldn't reach anyone in this session. Make sure the inviter is online and the invite is current, then try again.".to_string(); + let _ = session.update(cx, |session, cx| { + session.detach(DetachReason::JoinFailed(detail.clone()), cx); + }); + let _ = result_tx.try_send(Err(anyhow!(detail))); + return; + }, + } + + let reply_rx = match session.update(cx, |session, cx| session.start_join_snapshot_pull(inviter, result_tx.clone(), cx)) { + Ok(Ok(reply_rx)) => reply_rx, + Ok(Err(_)) => return, + Err(error) => { + let detail = format!("collaboration join session disappeared: {error}"); + let _ = result_tx.try_send(Err(anyhow!(detail))); + return; + }, + }; + + let joined = match reply_rx.recv().await { + Ok(Ok(bytes)) => { + tracing::info!(session = %session_id, snapshot_bytes = bytes.len(), "collaboration join snapshot pulled"); + session + .update(cx, |session, cx| match session.finish_join_snapshot(&bytes, cx) { + Ok(joined) => Ok(joined), + Err(error) => { + tracing::error!(session = %session.session, error = %format_args!("{error:#}"), "building collaboration document from snapshot failed"); + let detail = format!("building collaboration document failed: {error:#}"); + session.detach(DetachReason::JoinFailed(detail), cx); + Err(error.context("building collaboration document failed")) + }, + }) + .unwrap_or_else(|error| { + tracing::error!(error = %error, "collaboration join session disappeared while building snapshot"); + Err(anyhow!("collaboration join session disappeared: {error}")) + }) + }, + Ok(Err(error)) => { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "pulling collaboration join snapshot failed"); + let detail = format!("pulling collaboration snapshot failed: {error:#}"); + let _ = session.update(cx, |session, cx| { + session.detach(DetachReason::JoinFailed(detail.clone()), cx); + }); + Err(anyhow!(detail)) + }, + Err(error) => { + tracing::error!(session = %session_id, error = %error, "collaboration join snapshot reply channel closed"); + let detail = format!("collaboration snapshot reply channel closed: {error}"); + let _ = session.update(cx, |session, cx| { + session.detach(DetachReason::JoinFailed(detail.clone()), cx); + }); + Err(anyhow!(detail)) + }, + }; + let _ = result_tx.try_send(joined); + }) + .detach(); + + result_rx + } + + fn start_join_snapshot_pull( + &mut self, + inviter: PeerId, + result_tx: async_channel::Sender>, + cx: &mut Context, + ) -> Result>>> { + self.join_neighbor_tx = None; + self.phase = SessionPhase::Joining(JoinStage::FetchingSnapshot { got: 0, total: None }); + cx.notify(); + + let (reply_tx, reply_rx) = async_channel::bounded(1); + let (progress_tx, progress_rx) = async_channel::bounded(8); + let candidates = self.pull_candidates(Some(inviter)); + tracing::info!(session = %self.session, inviter = %inviter, candidate_count = candidates.len(), "requesting collaboration join snapshot"); + if let Err(error) = self.net_tx.try_send(NetCommand::PullSnapshot { + session: self.session, + candidates, + progress: Some(progress_tx), + reply: reply_tx, + }) { + tracing::error!(session = %self.session, inviter = %inviter, error = %error, "queueing collaboration join snapshot pull failed"); + let detail = format!("requesting collaboration snapshot failed: {error}"); + self.detach(DetachReason::JoinFailed(detail.clone()), cx); + let _ = result_tx.try_send(Err(anyhow!(detail.clone()))); + bail!(detail); + } + + cx.spawn(async move |session, cx| { + while let Ok(progress) = progress_rx.recv().await { + if session + .update(cx, |session, cx| { + if matches!(session.phase, SessionPhase::Joining(JoinStage::FetchingSnapshot { .. })) { + session.phase = SessionPhase::Joining(JoinStage::FetchingSnapshot { + got: progress.got, + total: Some(progress.total), + }); + cx.notify(); + } + }) + .is_err() + { + break; + } + } + }) + .detach(); + + cx.spawn(async move |session, cx| { + Timer::after(JOIN_SNAPSHOT_TIMEOUT).await; + let detail = "Joining this session timed out while fetching the document snapshot. Check your connection and try again.".to_string(); + let timed_out = session + .update(cx, |session, cx| { + if matches!(session.phase, SessionPhase::Joining(JoinStage::FetchingSnapshot { .. })) { + session.detach(DetachReason::JoinFailed(detail.clone()), cx); + true + } else { + false + } + }) + .unwrap_or(false); + if timed_out { + let _ = result_tx.try_send(Err(anyhow!(detail))); + } + }) + .detach(); + + Ok(reply_rx) + } + + pub fn attach_joined_editor(&mut self, panel_id: Uuid, editor: Entity, cx: &mut Context) -> Result<()> { + if self.runtime.is_none() { + tracing::warn!(session = %self.session, %panel_id, "cannot attach joined editor before snapshot load finishes"); + bail!("collaboration snapshot has not finished loading"); + } + + tracing::info!( + session = %self.session, + %panel_id, + pending_remote_updates = self.pending_remote_updates.len(), + pending_asset_records = self.pending_asset_records.len(), + "attaching joined collaboration editor", + ); + self.panel_id = Some(panel_id); + self.editor = Some(editor); + self.attach(cx); + + let pending_updates = std::mem::take(&mut self.pending_remote_updates); + for update in pending_updates { + self.import_update_bytes(&update, cx)?; + } + self.flush_pending_asset_records(cx); + asset_transfer::schedule_missing_assets(self, None, cx); + self.publish_digest(); + cx.notify(); + tracing::info!(session = %self.session, %panel_id, "joined collaboration editor attached"); + Ok(()) + } + + pub fn attach(&mut self, cx: &mut Context) { + tracing::debug!(session = %self.session, phase = ?self.phase, "attaching collaboration session hooks"); + self.attach_editor_hooks(cx); + self.attach_direct_request_pump(cx); + self.attach_undo_request_pump(cx); + self.attach_timers(cx); + self.refresh_runtime_version_vector(cx); + } + + pub fn runtime_handle(&self) -> Option { + self.runtime.clone() + } + + fn refresh_runtime_version_vector(&mut self, cx: &mut Context) { + let Some(runtime) = self.runtime.clone() else { + return; + }; + let session_id = self.session; + cx.spawn(async move |session, cx| { + let result = runtime.oplog_version_vector().await; + let _ = session.update(cx, |session, _| match result { + Ok(vv) => { + session.runtime_vv = vv; + session.publish_digest(); + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "reading collaboration runtime version vector failed"); + }, + }); + }) + .detach(); + } + + /// Re-synchronize anti-entropy state after a save/checkpoint that was applied + /// to the shared document runtime *outside* this session. + /// + /// Saving a document runs the editor's native save hook, which calls + /// `CrdtRuntimeHandle::checkpoint_package`. Recording that named revision is a + /// real Loro mutation: it advances the canonical frontier/version-vector. The + /// hook returns the resulting `RuntimeEvent::LocalUpdate` bytes, but the save + /// hook future runs without any GPUI context (see `NativeSaveHook`), so it + /// cannot route them back into this session. As a result our cached + /// `runtime_vv` would go stale and peers would not learn about the new + /// revision op until the next periodic digest (~10s). + /// + /// Re-reading the authoritative oplog version vector and re-publishing a + /// digest fixes the staleness and lets anti-entropy converge immediately: + /// peers see we are ahead (`SenderHasMissingOps`) and pull the revision op on + /// the spot. We intentionally do not re-publish update bytes or re-apply a + /// projection here — the checkpoint changed no visible content, and the + /// runtime remains the single Loro owner. + pub(super) fn refresh_after_external_checkpoint(&mut self, cx: &mut Context) { + if self.runtime.is_none() { + tracing::trace!(session = %self.session, "ignoring external checkpoint refresh because the session has no runtime"); + return; + } + tracing::debug!(session = %self.session, "refreshing collaboration anti-entropy state after an external document checkpoint"); + self.refresh_runtime_version_vector(cx); + } + + pub fn establish_local_peer(&mut self, peer: &flowstate_collab::ids::PeerId, cx: &mut Context) { + if self.presence.is_none() { + tracing::info!(session = %self.session, peer = %peer, "establishing local collaboration peer presence"); + let presence = PresenceStore::new(peer); + let session = self.session; + let net_tx = self.net_tx.clone(); + self + .loro_subscriptions + .push(presence.subscribe_local_updates(move |bytes| { + let bytes_len = bytes.len(); + if let Err(error) = net_tx.try_send(NetCommand::Publish { + session, + payload: PublishPayload::Presence(bytes.clone()), + }) { + tracing::warn!(%session, bytes = bytes_len, error = %error, "queueing collaboration presence publish failed"); + } else { + tracing::trace!(%session, bytes = bytes_len, "queued collaboration presence publish from local update"); + } + true + })); + self.presence = Some(presence); + } else { + tracing::debug!(session = %self.session, peer = %peer, "local collaboration peer presence already established"); + } + if let (Some(editor), Some(presence)) = (self.editor.clone(), self.presence.as_ref()) { + editor.update(cx, |editor, cx| editor.set_own_collaboration_caret_color(Some(presence.self_color()), cx)); + } + self.refresh_own_presence(cx); + self.endpoint_online = true; + self.phase = SessionPhase::Attached(Attachment { + connectivity: Connectivity::Online, + peers_present: self.peers_present(), + }); + self.publish_presence_snapshot(); + self.publish_digest(); + asset_transfer::schedule_missing_assets(self, None, cx); + cx.notify(); + tracing::info!(session = %self.session, peer = %peer, peers_present = self.peers_present(), "local collaboration peer established"); + } + + fn finish_join_snapshot(&mut self, snapshot: &[u8], cx: &mut Context) -> Result { + tracing::info!(session = %self.session, snapshot_bytes = snapshot.len(), "building collaboration document from join snapshot"); + if matches!(self.phase, SessionPhase::Detached(_)) { + bail!("collaboration join is no longer active"); + } + let total = snapshot.len() as u64; + self.phase = SessionPhase::Joining(JoinStage::FetchingSnapshot { + got: total, + total: Some(total), + }); + cx.notify(); + self.phase = SessionPhase::Joining(JoinStage::Building); + cx.notify(); + + let doc = LoroDoc::new(); + flowstate_document::loro_schema::configure_text_styles(&doc); + doc + .import_with(snapshot, "remote") + .context("importing collaboration snapshot failed")?; + let runtime = CrdtRuntime::from_doc(doc, None, None).context("creating joined collaboration CRDT runtime")?; + let mut document = runtime + .projection_snapshot() + .context("projecting joined Loro-native document")?; + let runtime = CrdtRuntimeHandle::spawn(runtime).context("starting joined collaboration CRDT runtime actor")?; + // §15/§31: bind this joiner's durable author identity to the joined runtime + // so their revisions record an author and `users_by_id` is populated. The + // user-registration op converges to peers via anti-entropy. Fire-and-forget + // and non-fatal: a failure must not break the join. This runtime later + // reaches `create_document_panel` through the `Handle` source, which + // deliberately skips re-binding to avoid a redundant second call. + let identity_runtime = runtime.clone(); + cx.spawn(async move |_, cx| { + let (user_id, display_name) = cx + .background_executor() + .spawn(async { load_local_user_identity() }) + .await; + if let Err(error) = identity_runtime + .set_author_identity(user_id, display_name) + .await + { + tracing::warn!(error = %format_args!("{error:#}"), "binding durable author identity to joined collaboration runtime failed"); + } + }) + .detach(); + document.theme = load_document_theme(); + tracing::info!( + session = %self.session, + paragraphs = document.paragraphs.len(), + blocks = document.blocks.len(), + assets = document.assets.assets.len(), + "built collaboration document from join snapshot", + ); + + self.runtime = Some(runtime); + self.runtime_vv.clear(); + Ok(JoinedDocument { + session: self.session, + title: format!("{} (shared)", self.title), + document, + }) + } + + pub fn detach(&mut self, reason: DetachReason, cx: &mut Context) -> bool { + if matches!(self.phase, SessionPhase::Detached(_)) { + tracing::debug!(session = %self.session, ?reason, "collaboration session already detached"); + return false; + } + + tracing::warn!(session = %self.session, ?reason, phase = ?self.phase, "detaching collaboration session"); + let user_left = matches!(reason, DetachReason::UserLeft); + let fatal_detail = match &reason { + DetachReason::Fatal(detail) => Some(detail.clone()), + DetachReason::UserLeft | DetachReason::JoinFailed(_) => None, + }; + if let Some(presence) = &self.presence { + presence.delete_self(); + self.publish_presence_bytes(presence.encode_self()); + } + if let Err(error) = self + .net_tx + .try_send(NetCommand::LeaveSession { session: self.session }) + { + tracing::warn!(session = %self.session, error = %error, "queueing collaboration leave-session command failed during detach"); + } + self.flush_pending_asset_records(cx); + if let Some(editor) = self.editor.clone() { + self.flush_local_edits(editor, cx); + } + + if let Some(editor) = self.editor.clone() { + editor.update(cx, |editor, cx| { + editor.set_recovery_path(None, cx); + editor.set_collaboration_role(None, cx); + editor.set_session_undo_redirect(None); + editor.set_session_capture(false); + editor.set_runtime_capture(true); + editor.set_own_collaboration_caret_color(None, cx); + editor.clear_undo_redo_stacks(); + editor.set_external_carets(Vec::new(), cx); + }); + } + + self.editor_subscriptions.clear(); + self.loro_subscriptions.clear(); + self.presence = None; + self.runtime = None; + self.runtime_vv.clear(); + self.pending_asset_records.clear(); + self.pending_remote_updates.clear(); + self.local_edit_flush_pending = false; + self.neighbors.clear(); + self.asset_pulls_in_flight.clear(); + self.zero_neighbors_since = Some(Instant::now()); + self.inbound_since_last_digest = false; + self.quiet_digest_rounds = 0; + self.next_recovery_at = None; + self.awaiting_recovery_neighbor_until = None; + self.probe_pending = false; + self.last_probe_failed = false; + self.join_neighbor_tx = None; + self.presence_refresh_pending = false; + self.presence_refresh_generation = self.presence_refresh_generation.wrapping_add(1); + self.external_caret_refresh_pending = false; + self.external_caret_refresh_generation = self.external_caret_refresh_generation.wrapping_add(1); + self.phase = SessionPhase::Detached(reason); + if user_left { + cx.emit(SessionNotice::LeftSession); + } else if let Some(detail) = fatal_detail { + cx.emit(SessionNotice::Disconnected(detail)); + } + cx.notify(); + tracing::info!(session = %self.session, "collaboration session detached and cleaned up"); + true + } + + pub fn flush_local_edits(&mut self, editor: Entity, cx: &mut Context) { + if matches!(self.phase, SessionPhase::Detached(_) | SessionPhase::Joining(_)) { + tracing::trace!(session = %self.session, phase = ?self.phase, "skipping local collaboration edit flush for inactive phase"); + return; + } + + if editor.read(cx).runtime_edit_in_flight() { + tracing::trace!(session = %self.session, "deferring local collaboration edit flush until the current runtime batch is acknowledged"); + return; + } + + let edits = editor.update(cx, |editor, _| editor.take_pending_session_edits()); + let edit_count = edits.len(); + let base_frontier = edits + .iter() + .find(|edit| !edit.semantic_commands.is_empty()) + .map(|edit| edit.base_frontier.clone()) + .unwrap_or_default(); + debug_assert!( + edits + .iter() + .filter(|edit| !edit.semantic_commands.is_empty()) + .all(|edit| edit.base_frontier == base_frontier), + "queued collaboration commands must share one projection frontier", + ); + let selection_after = edits + .iter() + .rev() + .find_map(|edit| edit.selection_after.clone()); + let commands = coalesce_collaboration_commands(edits.into_iter().flat_map(|edit| edit.semantic_commands)); + let operation_count = commands.len(); + let acknowledge_without_projection_replay = !commands.is_empty() + && commands + .iter() + .all(SemanticEditCommand::can_acknowledge_without_projection_replay); + if edit_count == 0 || operation_count == 0 { + tracing::trace!(session = %self.session, edit_count, operation_count, "no local collaboration edits to flush"); + return; + } + tracing::debug!(session = %self.session, edit_count, operation_count, "flushing local collaboration edits into Loro"); + let Some(runtime) = self.runtime.clone() else { + tracing::warn!(session = %self.session, edit_count, operation_count, "cannot flush local collaboration edits because Loro doc is missing"); + return; + }; + editor.update(cx, |editor, _| editor.begin_runtime_edit()); + let assets = editor + .read(cx) + .document() + .assets + .assets + .values() + .cloned() + .collect(); + let session_id = self.session; + let retry_edit = (!commands.is_empty()).then(|| SemanticCommandBatch { + base_frontier: base_frontier.clone(), + semantic_commands: commands.clone(), + selection_after: selection_after.clone(), + }); + cx.spawn(async move |session, cx| { + let result = runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after.clone()) + .await; + let stale_snapshot = match &result { + Err(error) if error.downcast_ref::().is_some() => { + runtime.projection_snapshot().await.ok() + }, + _ => None, + }; + let _ = session.update(cx, |session, cx| match result { + Ok(events) => { + if let Err(error) = session.apply_local_runtime_events( + events, + acknowledge_without_projection_replay, + selection_after, + cx, + ) { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "applying local runtime projection failed"); + session.detach(DetachReason::Fatal(format!("applying local collaboration edit failed: {error:#}")), cx); + return; + } + session.last_document_activity = Instant::now(); + }, + Err(error) => { + if let Some(document) = stale_snapshot { + tracing::debug!(session = %session_id, "discarding stale optimistic projection and restoring the canonical collaboration projection"); + if let Some(editor) = session.editor.clone() { + editor.update(cx, |editor, cx| { + editor.replace_document_projection_replaying_pending(document, retry_edit.into_iter().collect(), None, cx); + }); + } + } else { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "capturing local collaboration edit failed"); + if let Some(editor) = session.editor.clone() { + editor.update(cx, |editor, cx| editor.complete_runtime_edit(None, cx)); + } + session.detach(DetachReason::Fatal(format!("capturing local collaboration edit failed: {error:#}")), cx); + } + }, + }); + }) + .detach(); + } + + pub(super) fn apply_runtime_events(&mut self, events: Vec, apply_projection: bool, cx: &mut Context) -> Result<()> { + for event in events { + match event { + RuntimeEvent::LocalUpdate { bytes, version_vector, .. } => { + self.runtime_vv = version_vector; + self.publish_update_bytes(bytes); + }, + RuntimeEvent::RemoteUpdateApplied { pending, version_vector, .. } => { + self.runtime_vv = version_vector; + if let Some(pending) = pending { + tracing::debug!( + session = %self.session, + pending_ranges = pending.iter().count(), + "remote collaboration update has pending Loro dependencies; requesting anti-entropy pull immediately", + ); + if let Some(from) = self.pull_candidates(None).first().copied() { + let our_vv = self.runtime_vv.clone(); + self.start_update_pull(from, our_vv, cx); + } else { + tracing::warn!(session = %self.session, "cannot pull pending Loro dependencies because no collaboration peers are available"); + } + } + }, + RuntimeEvent::ProjectionUpdated { + document, version_vector, .. + } if apply_projection => { + self.runtime_vv = version_vector; + self.apply_runtime_projection(*document, cx)?; + }, + RuntimeEvent::ProjectionPatched { + patches, + frontier, + version_vector, + .. + } if apply_projection => { + self.runtime_vv = version_vector; + self.apply_runtime_patches(patches, frontier, cx); + }, + RuntimeEvent::RevisionOpened { document, .. } if apply_projection => { + self.apply_runtime_projection(*document, cx)?; + }, + RuntimeEvent::SelectionRestored { selection } if apply_projection => { + if let Some(editor) = self.editor.clone() { + editor.update(cx, |editor, cx| editor.restore_runtime_selection(selection, cx)); + } + }, + RuntimeEvent::RevisionForked { .. } + | RuntimeEvent::ProjectionUpdated { .. } + | RuntimeEvent::ProjectionPatched { .. } + | RuntimeEvent::RevisionOpened { .. } + | RuntimeEvent::SelectionRestored { .. } => {}, + } + } + Ok(()) + } + + fn apply_local_runtime_events( + &mut self, + events: Vec, + acknowledge_without_projection_replay: bool, + selection_after: Option, + cx: &mut Context, + ) -> Result<()> { + if acknowledge_without_projection_replay { + let frontier = events + .iter() + .filter_map(RuntimeEvent::frontier) + .next_back() + .map(ToOwned::to_owned); + + // Publish update bytes and advance collaboration version-vector state, + // but do not reapply the projection echo of an optimistic text/style + // mutation that is already visible in the editor. + self.apply_runtime_events(events, false, cx)?; + if let Some(editor) = self.editor.clone() { + editor.update(cx, |editor, cx| { + if let Some(frontier) = frontier { + editor.acknowledge_runtime_edit(frontier, None, cx); + } else { + editor.complete_runtime_edit(None, cx); + } + }); + } + } else { + let frontier = events + .iter() + .filter_map(RuntimeEvent::frontier) + .next_back() + .map(ToOwned::to_owned); + self.apply_runtime_events(events, true, cx)?; + if let Some(editor) = self.editor.clone() { + editor.update(cx, |editor, cx| { + if let Some(frontier) = frontier { + editor.complete_runtime_edit_replaying_pending(frontier, Vec::new(), selection_after, cx); + } else { + editor.complete_runtime_edit(selection_after, cx); + } + }); + } + } + self.last_self_check = None; + self.refresh_external_carets(cx); + Ok(()) + } + + fn apply_runtime_patches(&mut self, patches: Vec, frontier: Vec, cx: &mut Context) { + let Some(editor) = self.editor.clone() else { + return; + }; + editor.update(cx, |editor, cx| editor.apply_projection_patches_at_frontier(&patches, frontier, cx)); + self.last_document_activity = Instant::now(); + self.last_self_check = None; + self.refresh_external_carets(cx); + } + + fn apply_runtime_projection(&mut self, mut document: DocumentProjection, cx: &mut Context) -> Result<()> { + let Some(editor) = self.editor.clone() else { + return Ok(()); + }; + let current = editor.read(cx).document().clone(); + document.assets = current.assets; + document.theme = current.theme; + editor.update(cx, |editor, cx| editor.replace_document_projection(document, cx)); + self.last_document_activity = Instant::now(); + self.last_self_check = None; + self.refresh_external_carets(cx); + Ok(()) + } + + fn publish_update_bytes(&self, bytes: Vec) { + if bytes.is_empty() { + tracing::trace!(session = %self.session, "skipping empty collaboration update publish"); + return; + } + let bytes_len = bytes.len(); + if let Err(error) = self.net_tx.try_send(NetCommand::Publish { + session: self.session, + payload: PublishPayload::Update(bytes), + }) { + tracing::warn!(session = %self.session, bytes = bytes_len, error = %error, "queueing collaboration update publish failed"); + } else { + tracing::trace!(session = %self.session, bytes = bytes_len, "queued collaboration update publish from CRDT runtime event"); + } + } + + pub fn handle_gossip(&mut self, from: flowstate_collab::ids::PeerId, msg: GossipMsg, cx: &mut Context) { + let gossip_kind = msg.kind(); + let gossip_payload_bytes = msg.payload_len(); + tracing::trace!(session = %self.session, from = %from, gossip_kind, gossip_payload_bytes, "handling collaboration gossip message"); + self.known_peers.insert(from); + self.note_inbound_traffic(cx); + let result = match msg { + GossipMsg::Update(bytes) => self + .import_update_bytes(&bytes, cx) + .map(|()| asset_transfer::schedule_missing_assets(self, Some(from), cx)), + GossipMsg::UpdateAvailable { blob, len } => { + tracing::debug!(session = %self.session, from = %from, ?blob, bytes = len, "collaboration update available via direct blob pull"); + self.pull_blob(from, blob, cx); + Ok(()) + }, + GossipMsg::Presence(bytes) => { + self.apply_presence(&bytes, cx); + Ok(()) + }, + GossipMsg::Digest { session, vv } => self.handle_digest(from, session, &vv, cx), + }; + if let Err(error) = result { + tracing::error!(session = %self.session, from = %from, gossip_kind, error = %format_args!("{error:#}"), "collaboration gossip handling failed"); + self.detach(DetachReason::Fatal(format!("collaboration update failed: {error:#}")), cx); + } + } + + pub fn neighbor_up(&mut self, peer: flowstate_collab::ids::PeerId, cx: &mut Context) { + let inserted = self.neighbors.insert(peer); + self.known_peers.insert(peer); + self.zero_neighbors_since = None; + self.last_probe_failed = false; + if let Some(join_neighbor_tx) = self.join_neighbor_tx.take() { + let _ = join_neighbor_tx.try_send(JoinNeighborSignal::NeighborUp); + } + self.anti_entropy.on_neighbor_up(); + self.mark_online(cx); + self.publish_presence_snapshot(); + self.publish_digest(); + asset_transfer::schedule_missing_assets(self, Some(peer), cx); + cx.notify(); + tracing::info!(session = %self.session, peer = %peer, inserted, neighbors = self.neighbors.len(), "collaboration neighbor up"); + } + + pub fn neighbor_down(&mut self, peer: flowstate_collab::ids::PeerId, cx: &mut Context) { + let removed = self.neighbors.remove(&peer); + if self.neighbors.is_empty() && self.zero_neighbors_since.is_none() { + self.zero_neighbors_since = Some(Instant::now()); + } + self.evaluate_connectivity(cx); + cx.notify(); + tracing::info!(session = %self.session, peer = %peer, removed, neighbors = self.neighbors.len(), "collaboration neighbor down"); + } + + pub fn handle_gossip_lagged(&mut self, cx: &mut Context) { + tracing::warn!(session = %self.session, neighbors = self.neighbors.len(), "collaboration gossip lagged; scheduling recovery"); + self.publish_digest(); + let peer = self.neighbors.iter().next().copied(); + let vv = self.runtime_vv.clone(); + let action = self.anti_entropy.on_lagged(peer, vv); + self.handle_gap_action(action, cx); + } + + pub fn set_endpoint_online(&mut self, online: bool, cx: &mut Context) { + tracing::info!(session = %self.session, online, previous_online = self.endpoint_online, "collaboration endpoint online state applied to session"); + self.endpoint_online = online; + if online { + self.last_probe_failed = false; + } + if online { + if self.peers_present() == 0 || !self.neighbors.is_empty() { + self.mark_online(cx); + } + } else { + self.evaluate_connectivity(cx); + } + cx.notify(); + } + + pub fn handle_incompatible_version(&mut self, peer: PeerId, cx: &mut Context) { + if self.incompatible_version_peers.insert(peer) { + cx.emit(SessionNotice::IncompatibleVersion(peer.to_string())); + } + } + + fn attach_editor_hooks(&mut self, cx: &mut Context) { + if !self.editor_subscriptions.is_empty() { + tracing::trace!(session = %self.session, "collaboration editor hooks already attached"); + return; + } + let Some(editor) = self.editor.clone() else { + tracing::warn!(session = %self.session, "cannot attach collaboration editor hooks because editor is missing"); + return; + }; + + tracing::debug!(session = %self.session, "attaching collaboration editor hooks"); + editor.update(cx, |editor, cx| { + if editor.document_path().is_none() { + editor.set_recovery_path(Some(presence_view::collaboration_recovery_path(self.session, &self.title)), cx); + tracing::debug!(session = %self.session, "set collaboration recovery path for untitled document"); + } + editor.clear_undo_redo_stacks(); + editor.set_runtime_capture(false); + editor.set_session_capture(true); + editor.set_native_undo_hook(None); + let undo_tx = self.undo_tx.clone(); + editor.set_session_undo_redirect(Some(Rc::new(move |redirect: UndoRedirect| { + let _ = undo_tx.try_send(redirect); + }))); + }); + + self + .editor_subscriptions + .push(cx.observe(&editor, |session, editor, cx| { + session.schedule_local_edit_flush(editor.clone(), cx); + session.flush_pending_asset_records(cx); + })); + + self + .editor_subscriptions + .push(cx.subscribe(&editor, |session, _, event: &EditorEvent, cx| { + if matches!(event, EditorEvent::SelectionChanged { .. }) { + tracing::trace!(session = %session.session, "collaboration local selection changed; refreshing presence"); + session.schedule_own_presence_refresh(cx); + } + })); + tracing::debug!(session = %self.session, subscriptions = self.editor_subscriptions.len(), "collaboration editor hooks attached"); + } + + fn schedule_local_edit_flush(&mut self, editor: Entity, cx: &mut Context) { + if self.local_edit_flush_pending { + return; + } + self.local_edit_flush_pending = true; + cx.spawn(async move |session, cx| { + Timer::after(Duration::from_millis(LOCAL_EDIT_FLUSH_DEBOUNCE_MS)).await; + let _ = session.update(cx, |session, cx| { + session.local_edit_flush_pending = false; + session.flush_local_edits(editor, cx); + session.flush_pending_asset_records(cx); + }); + }) + .detach(); + } + + fn attach_undo_request_pump(&mut self, cx: &mut Context) { + if self.undo_pump_started { + tracing::trace!(session = %self.session, "collaboration undo request pump already started"); + return; + } + self.undo_pump_started = true; + let requests = self.undo_rx.clone(); + let session_id = self.session; + tracing::debug!(session = %session_id, "starting collaboration undo request pump"); + cx.spawn(async move |session, cx| { + while let Ok(redirect) = requests.recv().await { + tracing::debug!(session = %session_id, ?redirect, "received collaboration undo redirect"); + if session + .update(cx, |session, cx| { + if let Err(error) = session.apply_loro_undo_redirect(redirect, cx) { + tracing::error!(session = %session.session, error = %format_args!("{error:#}"), "collaboration undo redirect failed"); + session.detach(DetachReason::Fatal(format!("collaboration undo failed: {error:#}")), cx); + } + }) + .is_err() + { + tracing::debug!(session = %session_id, "collaboration undo request pump session disappeared"); + break; + } + } + tracing::debug!(session = %session_id, "collaboration undo request pump stopped"); + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rich_text_element::{DocumentOffset, RunStyles}; + + #[test] + fn coalesces_adjacent_insert_text_commands() { + let styles = RunStyles::default(); + let commands = vec![ + SemanticEditCommand::InsertText { + at: DocumentOffset { paragraph: 0, byte: 0 }, + text: "h".to_string(), + styles, + }, + SemanticEditCommand::InsertText { + at: DocumentOffset { paragraph: 0, byte: 1 }, + text: "i".to_string(), + styles, + }, + SemanticEditCommand::InsertText { + at: DocumentOffset { paragraph: 1, byte: 0 }, + text: "!".to_string(), + styles, + }, + ]; + + let coalesced = coalesce_collaboration_commands(commands); + + assert_eq!(coalesced.len(), 2); + let SemanticEditCommand::InsertText { at, text, .. } = &coalesced[0] else { + panic!("expected merged insert"); + }; + assert_eq!(*at, DocumentOffset { paragraph: 0, byte: 0 }); + assert_eq!(text, "hi"); + } +} diff --git a/crates/flowstate/src/collab/session_io.rs b/crates/flowstate/src/collab/session_io.rs new file mode 100644 index 00000000..384c9ded --- /dev/null +++ b/crates/flowstate/src/collab/session_io.rs @@ -0,0 +1,437 @@ +use anyhow::{Context as _, Result, anyhow}; +use flowstate_collab::{ + SessionId, + crdt_runtime::SemanticCommand, + net::{ + NetCommand, PublishPayload, + anti_entropy::{GapAction, VersionVectorRelation}, + direct::DirectServeRequest, + }, + proto_direct::AssetBytes, +}; +use gpui::Context; +use loro::VersionVector; + +use crate::rich_text_element::{AssetId, AssetRecord, UndoRedirect}; + +use super::{CollabSession, DetachReason}; + +impl CollabSession { + pub fn import_update_bytes(&mut self, bytes: &[u8], cx: &mut Context) -> Result<()> { + if bytes.is_empty() { + tracing::trace!(session = %self.session, "skipping empty collaboration update import"); + return Ok(()); + } + if self.runtime.is_none() || self.editor.is_none() { + tracing::debug!( + session = %self.session, + bytes = bytes.len(), + queued_updates = self.pending_remote_updates.len() + 1, + has_runtime = self.runtime.is_some(), + has_editor = self.editor.is_some(), + "queueing remote collaboration update until session is attached", + ); + self.pending_remote_updates.push(bytes.to_vec()); + return Ok(()); + } + + tracing::debug!(session = %self.session, bytes = bytes.len(), "importing remote collaboration update"); + let runtime = self + .runtime + .clone() + .context("collaboration session has no CRDT runtime")?; + let bytes = bytes.to_vec(); + let bytes_len = bytes.len(); + let session_id = self.session; + cx.spawn(async move |session, cx| { + let result = runtime.import_remote_update(bytes).await; + let _ = session.update(cx, |session, cx| match result { + Ok(events) => { + if let Err(error) = session.apply_runtime_events(events, true, cx) { + tracing::error!(session = %session_id, bytes = bytes_len, error = %format_args!("{error:#}"), "applying remote collaboration projection failed"); + session.detach(DetachReason::Fatal(format!("applying collaboration update failed: {error:#}")), cx); + } else { + tracing::debug!(session = %session_id, bytes = bytes_len, "remote collaboration update imported and projected"); + } + }, + Err(error) => { + tracing::error!(session = %session_id, bytes = bytes_len, error = %format_args!("{error:#}"), "remote collaboration update import failed"); + session.detach(DetachReason::Fatal(format!("importing collaboration update failed: {error:#}")), cx); + }, + }); + }) + .detach(); + Ok(()) + } + + pub(super) fn attach_direct_request_pump(&mut self, cx: &mut Context) { + if self.direct_pump_started { + tracing::trace!(session = %self.session, "collaboration direct request pump already started"); + return; + } + self.direct_pump_started = true; + let requests = self.direct_rx.clone(); + let session_id = self.session; + tracing::debug!(session = %session_id, "starting collaboration direct request pump"); + cx.spawn(async move |session, cx| { + while let Ok(request) = requests.recv().await { + tracing::trace!(session = %session_id, request_kind = direct_serve_request_kind(&request), "received collaboration direct serve request"); + if session + .update(cx, |session, cx| session.handle_direct_request(request, cx)) + .is_err() + { + tracing::debug!(session = %session_id, "collaboration direct request pump session disappeared"); + break; + } + } + tracing::debug!(session = %session_id, "collaboration direct request pump stopped"); + }) + .detach(); + } + + pub(super) fn handle_digest( + &mut self, + from: flowstate_collab::ids::PeerId, + digest_session: SessionId, + vv: &[u8], + cx: &mut Context, + ) -> Result<()> { + if self.runtime.is_none() { + tracing::debug!(session = %self.session, from = %from, digest_session = %digest_session, vv_bytes = vv.len(), "ignored collaboration digest because Loro doc is missing"); + return Ok(()); + } + let sender_vv = match VersionVector::decode(vv).context("decoding collaboration digest failed") { + Ok(sender_vv) => sender_vv, + Err(error) => { + tracing::warn!(session = %self.session, from = %from, digest_session = %digest_session, vv_bytes = vv.len(), error = %format_args!("{error:#}"), "decoding collaboration digest failed"); + return Err(error); + }, + }; + if self.runtime_vv.is_empty() { + tracing::debug!(session = %self.session, from = %from, "ignored collaboration digest until local version vector is initialized"); + return Ok(()); + } + let our_vv = VersionVector::decode(&self.runtime_vv).context("decoding local collaboration version vector failed")?; + let relation = match sender_vv.partial_cmp(&our_vv) { + Some(std::cmp::Ordering::Equal) => VersionVectorRelation::Equal, + Some(std::cmp::Ordering::Greater) => VersionVectorRelation::SenderHasMissingOps, + Some(std::cmp::Ordering::Less) => VersionVectorRelation::WeHaveMissingOps, + None => VersionVectorRelation::Concurrent, + }; + let action = self + .anti_entropy + .consider_digest(from, digest_session, relation, self.runtime_vv.clone()); + tracing::trace!( + session = %self.session, + from = %from, + digest_session = %digest_session, + vv_bytes = vv.len(), + ?relation, + action = gap_action_kind(&action), + "handled collaboration digest", + ); + self.handle_gap_action(action, cx); + Ok(()) + } + + pub(super) fn pull_blob(&mut self, from: flowstate_collab::ids::PeerId, blob: flowstate_collab::BlobId, cx: &mut Context) { + let (reply_tx, reply_rx) = async_channel::bounded(1); + let candidates = self.pull_candidates(Some(from)); + tracing::debug!(session = %self.session, from = %from, ?blob, candidate_count = candidates.len(), "requesting collaboration update blob pull"); + if let Err(error) = self.net_tx.try_send(NetCommand::PullBlob { + session: self.session, + candidates, + blob, + reply: reply_tx, + }) { + tracing::warn!(session = %self.session, from = %from, ?blob, error = %error, "queueing collaboration blob pull failed"); + return; + } + let session_id = self.session; + cx.spawn(async move |session, cx| { + let result = reply_rx.recv().await; + let _ = session.update(cx, |session, cx| match result { + Ok(Ok(bytes)) => { + tracing::debug!(session = %session_id, ?blob, bytes = bytes.len(), "collaboration blob pull succeeded"); + if let Err(error) = session.import_update_bytes(&bytes, cx) { + tracing::error!(session = %session_id, ?blob, error = %format_args!("{error:#}"), "importing pulled collaboration blob failed"); + session.detach(DetachReason::Fatal(format!("pulling collaboration blob failed: {error:#}")), cx); + } + }, + Ok(Err(error)) => tracing::warn!(session = %session_id, ?blob, error = %format_args!("{error:#}"), "collaboration blob pull failed"), + Err(error) => tracing::warn!(session = %session_id, ?blob, error = %error, "collaboration blob pull reply channel closed"), + }); + }) + .detach(); + } + + pub(super) fn publish_digest(&self) { + if self.runtime.is_some() { + let vv = self.runtime_vv.clone(); + if vv.is_empty() { + tracing::trace!(session = %self.session, "skipping collaboration digest publish until runtime version vector is initialized"); + return; + } + let vv_bytes = vv.len(); + if let Err(error) = self.net_tx.try_send(NetCommand::Publish { + session: self.session, + payload: PublishPayload::Digest { vv }, + }) { + tracing::warn!(session = %self.session, vv_bytes, error = %error, "queueing collaboration digest publish failed"); + } else { + tracing::trace!(session = %self.session, vv_bytes, "queued collaboration digest publish"); + } + } else { + tracing::trace!(session = %self.session, "skipping collaboration digest publish because Loro doc is missing"); + } + } + + pub(super) fn flush_pending_asset_records(&mut self, cx: &mut Context) -> bool { + let Some(editor) = self.editor.clone() else { + tracing::trace!(session = %self.session, pending_asset_records = self.pending_asset_records.len(), "cannot flush collaboration asset records because editor is missing"); + return false; + }; + let deferred = editor.read(cx).projection_apply_deferred(); + if self.pending_asset_records.is_empty() || deferred { + tracing::trace!( + session = %self.session, + pending_asset_records = self.pending_asset_records.len(), + deferred, + "collaboration asset record flush skipped", + ); + return false; + } + let asset_records = std::mem::take(&mut self.pending_asset_records); + tracing::debug!(session = %self.session, asset_records = asset_records.len(), "flushing collaboration asset records to editor"); + editor.update(cx, |editor, cx| { + editor.clear_undo_redo_stacks(); + editor.apply_synced_asset_records(&asset_records, cx); + }); + self.last_document_activity = std::time::Instant::now(); + self.refresh_external_carets(cx); + tracing::debug!(session = %self.session, asset_records = asset_records.len(), "collaboration asset records flushed to editor"); + true + } + + pub(super) fn apply_loro_undo_redirect(&mut self, redirect: UndoRedirect, cx: &mut Context) -> Result<()> { + if self.runtime.is_none() || self.editor.is_none() { + tracing::warn!( + session = %self.session, + ?redirect, + has_runtime = self.runtime.is_some(), + has_editor = self.editor.is_some(), + "cannot apply collaboration undo redirect because session state is incomplete", + ); + return Ok(()); + } + + tracing::debug!(session = %self.session, ?redirect, "applying collaboration undo redirect"); + let command = match redirect { + UndoRedirect::Undo => SemanticCommand::Undo, + UndoRedirect::Redo => SemanticCommand::Redo, + }; + let runtime = self + .runtime + .clone() + .context("collaboration session has no CRDT runtime")?; + let session_id = self.session; + cx.spawn(async move |session, cx| { + let result = runtime.command(command).await; + let _ = session.update(cx, |session, cx| match result { + Ok(events) => { + let applied = !events.is_empty(); + tracing::debug!(session = %session_id, ?redirect, applied, "collaboration undo redirect applied"); + if applied { + if let Err(error) = session.apply_runtime_events(events, true, cx) { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "applying collaboration undo projection failed"); + } + session.publish_digest(); + } + }, + Err(error) => tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "applying collaboration undo operation failed"), + }); + }) + .detach(); + Ok(()) + } + + pub(super) fn handle_gap_action(&mut self, action: GapAction, cx: &mut Context) { + match action { + GapAction::None => {}, + GapAction::Pull { from, our_vv } => { + tracing::debug!(session = %self.session, from = %from, our_vv_bytes = our_vv.len(), "collaboration gap action requested update pull"); + self.start_update_pull(from, our_vv, cx); + }, + GapAction::LineageMismatch { from, expected, got } => { + tracing::warn!(session = %self.session, from = %from, expected = %expected, got = %got, "ignored mismatched collaboration digest"); + }, + } + } + + fn handle_direct_request(&mut self, request: DirectServeRequest, cx: &mut Context) { + tracing::trace!(session = %self.session, request_kind = direct_serve_request_kind(&request), "serving collaboration direct request from session"); + match request { + DirectServeRequest::Snapshot { reply } => { + let runtime = self.runtime.clone(); + let session_id = self.session; + cx.spawn(async move |_, _| { + let result = match runtime { + Some(runtime) => runtime.snapshot_bytes().await, + None => Err(anyhow!("collaboration session is not attached")), + }; + log_direct_serve_result(session_id, "snapshot", &result); + let _ = reply.send(result).await; + }) + .detach(); + }, + DirectServeRequest::Updates { have_vv, reply } => { + tracing::trace!(session = %self.session, have_vv_bytes = have_vv.len(), "serving collaboration updates request"); + let runtime = self.runtime.clone(); + let session_id = self.session; + cx.spawn(async move |_, _| { + let result = match runtime { + Some(runtime) => runtime.export_updates_for(have_vv).await, + None => Err(anyhow!("collaboration session is not attached")), + }; + log_direct_serve_result(session_id, "updates", &result); + let _ = reply.send(result).await; + }) + .detach(); + }, + DirectServeRequest::Asset { asset, reply } => { + let result = self.asset_bytes(asset, cx); + match &result { + Ok(bytes) => tracing::debug!(session = %self.session, asset, bytes = bytes.bytes.len(), "served collaboration asset direct request"), + Err(error) => { + tracing::warn!(session = %self.session, asset, error = %format_args!("{error:#}"), "serving collaboration asset direct request failed"); + }, + } + let _ = reply.try_send(result); + }, + } + } + + fn asset_bytes(&self, asset: u128, cx: &mut Context) -> Result { + let editor = self + .editor + .as_ref() + .context("collaboration session has no editor")?; + let bytes = editor + .read(cx) + .document() + .assets + .assets + .get(&AssetId(asset)) + .map(|record| record.bytes.as_ref().clone()) + .ok_or_else(|| anyhow!("collaboration asset {asset} is not available"))?; + tracing::debug!(session = %self.session, asset, bytes = bytes.len(), "exported collaboration asset bytes"); + Ok(AssetBytes { bytes }) + } + + pub(super) fn queue_asset_records(&mut self, mut asset_records: Vec<(AssetId, AssetRecord)>, cx: &mut Context) { + if asset_records.is_empty() { + tracing::trace!(session = %self.session, "no collaboration asset records to queue"); + return; + } + for (id, record) in &asset_records { + trace_asset_record(self.session, *id, record); + } + let canonical_records = asset_records + .iter() + .filter(|(_, record)| !record.is_loading_placeholder()) + .map(|(_, record)| record.clone()) + .collect::>(); + if !canonical_records.is_empty() + && let Some(runtime) = self.runtime.clone() + { + let session_id = self.session; + cx.spawn(async move |session, cx| { + let result = runtime + .apply_editor_commands(Vec::new(), Vec::new(), canonical_records, None) + .await; + let _ = session.update(cx, |session, cx| match result { + Ok(events) => { + if let Err(error) = session.apply_runtime_events(events, true, cx) { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "projecting fetched collaboration assets failed"); + } + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "recording fetched collaboration assets failed"); + }, + }); + }) + .detach(); + } + tracing::debug!(session = %self.session, asset_records = asset_records.len(), pending_before = self.pending_asset_records.len(), "queueing collaboration asset records"); + self.pending_asset_records.append(&mut asset_records); + let flushed = self.flush_pending_asset_records(cx); + tracing::trace!(session = %self.session, pending_after = self.pending_asset_records.len(), flushed, "collaboration asset record queue updated"); + } + + pub(super) fn start_update_pull(&mut self, from: flowstate_collab::ids::PeerId, our_vv: Vec, cx: &mut Context) { + let (reply_tx, reply_rx) = async_channel::bounded(1); + let candidates = self.pull_candidates(Some(from)); + tracing::debug!(session = %self.session, from = %from, our_vv_bytes = our_vv.len(), candidate_count = candidates.len(), "requesting collaboration update pull"); + let send_result = self.net_tx.try_send(NetCommand::PullUpdates { + session: self.session, + candidates, + our_vv, + reply: reply_tx, + }); + if let Err(error) = send_result { + tracing::warn!(session = %self.session, from = %from, error = %error, "queueing collaboration update pull failed"); + self.anti_entropy.finish_pull(); + return; + } + let session_id = self.session; + cx.spawn(async move |session, cx| { + let result = reply_rx.recv().await; + let _ = session.update(cx, |session, cx| { + session.anti_entropy.finish_pull(); + match result { + Ok(Ok(bytes)) => { + tracing::debug!(session = %session_id, from = %from, bytes = bytes.len(), "collaboration update pull succeeded"); + if bytes.is_empty() { + tracing::trace!(session = %session_id, from = %from, "collaboration update pull returned no missing updates"); + return; + } + if let Err(error) = session.import_update_bytes(&bytes, cx) { + tracing::error!(session = %session_id, from = %from, error = %format_args!("{error:#}"), "importing pulled collaboration updates failed"); + session.detach(DetachReason::Fatal(format!("pulling collaboration updates failed: {error:#}")), cx); + } + }, + Ok(Err(error)) => tracing::warn!(session = %session_id, from = %from, error = %format_args!("{error:#}"), "collaboration update pull failed"), + Err(error) => tracing::warn!(session = %session_id, from = %from, error = %error, "collaboration update pull reply channel closed"), + } + }); + }) + .detach(); + } +} + +fn direct_serve_request_kind(request: &DirectServeRequest) -> &'static str { + match request { + DirectServeRequest::Snapshot { .. } => "snapshot", + DirectServeRequest::Updates { .. } => "updates", + DirectServeRequest::Asset { .. } => "asset", + } +} + +fn gap_action_kind(action: &GapAction) -> &'static str { + match action { + GapAction::None => "none", + GapAction::Pull { .. } => "pull", + GapAction::LineageMismatch { .. } => "lineage_mismatch", + } +} + +fn log_direct_serve_result(session: SessionId, kind: &'static str, result: &Result>) { + match result { + Ok(bytes) => tracing::debug!(%session, kind, bytes = bytes.len(), "served collaboration direct payload"), + Err(error) => tracing::warn!(%session, kind, error = %format_args!("{error:#}"), "serving collaboration direct payload failed"), + } +} + +fn trace_asset_record(session: SessionId, id: AssetId, record: &AssetRecord) { + tracing::trace!(%session, ?id, bytes = record.bytes.len(), placeholder = record.is_loading_placeholder(), "queued collaboration asset record"); +} diff --git a/crates/flowstate/src/collab/session_presence.rs b/crates/flowstate/src/collab/session_presence.rs new file mode 100644 index 00000000..05cd94e6 --- /dev/null +++ b/crates/flowstate/src/collab/session_presence.rs @@ -0,0 +1,265 @@ +use std::{collections::HashMap, time::Duration}; + +use flowstate_collab::{ + crdt_runtime::RuntimePresenceCaretRequest, + presence::{PresenceState, PresenceStore}, +}; +use gpui::{Context, Timer}; + +use super::{CollabSession, SessionNotice}; + +const PRESENCE_REFRESH_DEBOUNCE: Duration = Duration::from_millis(50); +const EXTERNAL_CARET_REFRESH_DEBOUNCE: Duration = Duration::from_millis(16); + +impl CollabSession { + pub(super) fn apply_presence(&mut self, bytes: &[u8], cx: &mut Context) { + if let Some(presence) = &self.presence { + tracing::trace!(session = %self.session, bytes = bytes.len(), "applying remote collaboration presence update"); + let before = remote_roster(presence); + if let Err(error) = presence.apply(bytes) { + tracing::warn!(session = %self.session, bytes = bytes.len(), error = %format_args!("{error:#}"), "collaboration presence update failed"); + return; + } + let roster_changed = self.emit_presence_roster_diff(before, cx); + let peer_count_changed = self.refresh_peer_count(); + self.evaluate_connectivity(cx); + self.refresh_external_carets(cx); + if roster_changed || peer_count_changed { + cx.notify(); + } + } else { + tracing::debug!(session = %self.session, bytes = bytes.len(), "ignored collaboration presence update before local presence was established"); + } + } + + pub(super) fn refresh_own_presence(&mut self, cx: &mut Context) { + if self.presence.is_none() { + tracing::trace!(session = %self.session, "skipping own collaboration presence refresh because store is missing"); + return; + } + let selection = self + .editor + .as_ref() + .map(|editor| editor.read(cx).selection().clone()); + let runtime = self.runtime.clone(); + let session_id = self.session; + self.presence_refresh_generation = self.presence_refresh_generation.wrapping_add(1); + let generation = self.presence_refresh_generation; + cx.spawn(async move |session, cx| { + let selection = match (runtime, selection) { + (Some(runtime), Some(selection)) => runtime.presence_selection(selection).await, + _ => Ok(None), + }; + let _ = session.update(cx, |session, cx| match selection { + Ok(selection) => { + if session.presence_refresh_generation != generation { + tracing::trace!(session = %session_id, generation, current_generation = session.presence_refresh_generation, "skipping stale own collaboration presence refresh"); + return; + } + let state = PresenceState { + name: default_presence_name(), + selection, + }; + tracing::trace!(session = %session_id, has_selection = state.selection.is_some(), "refreshing own collaboration presence"); + if let Some(presence) = &session.presence + && let Err(error) = presence.set_self(&state) + { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "collaboration presence encode failed"); + } + if session.refresh_peer_count() { + cx.notify(); + } + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "resolving own collaboration selection failed"); + }, + }); + }) + .detach(); + } + + pub(super) fn schedule_own_presence_refresh(&mut self, cx: &mut Context) { + if self.presence.is_none() || self.presence_refresh_pending { + return; + } + self.presence_refresh_pending = true; + cx.spawn(async move |session, cx| { + Timer::after(PRESENCE_REFRESH_DEBOUNCE).await; + let _ = session.update(cx, |session, cx| { + session.presence_refresh_pending = false; + session.refresh_own_presence(cx); + }); + }) + .detach(); + } + + pub(super) fn remove_outdated_presence(&mut self, cx: &mut Context) -> bool { + let Some(presence) = &self.presence else { + return false; + }; + let before = remote_roster(presence); + presence.remove_outdated(); + self.emit_presence_roster_diff(before, cx) + } + + pub(super) fn refresh_external_carets(&mut self, cx: &mut Context) { + if self.external_caret_refresh_pending { + return; + } + self.external_caret_refresh_pending = true; + cx.spawn(async move |session, cx| { + Timer::after(EXTERNAL_CARET_REFRESH_DEBOUNCE).await; + let _ = session.update(cx, |session, cx| { + session.external_caret_refresh_pending = false; + session.refresh_external_carets_now(cx); + }); + }) + .detach(); + } + + fn refresh_external_carets_now(&mut self, cx: &mut Context) { + let Some(presence) = &self.presence else { + tracing::trace!(session = %self.session, "skipping external caret refresh because presence is missing"); + return; + }; + let Some(runtime) = self.runtime.clone() else { + tracing::trace!(session = %self.session, "skipping external caret refresh because Loro doc is missing"); + return; + }; + let Some(editor) = self.editor.clone() else { + tracing::trace!(session = %self.session, "skipping external caret refresh because editor is missing"); + return; + }; + let self_key = presence.self_key().to_string(); + let requests: Vec = presence + .roster() + .into_iter() + .filter(|entry| entry.key != self_key) + .filter_map(|entry| { + entry + .selection + .map(|selection| RuntimePresenceCaretRequest { + selection, + color_rgb: entry.color_rgb, + }) + }) + .collect(); + let session_id = self.session; + self.external_caret_refresh_generation = self.external_caret_refresh_generation.wrapping_add(1); + let generation = self.external_caret_refresh_generation; + if requests.is_empty() { + tracing::trace!(session = %self.session, "clearing collaboration external carets because no remote selections are present"); + editor.update(cx, |editor, cx| editor.set_external_carets(Vec::new(), cx)); + return; + } + cx.spawn(async move |session, cx| { + let result = runtime.resolve_presence_carets(requests).await; + let _ = session.update(cx, |session, cx| match result { + Ok(resolved) => { + if session.external_caret_refresh_generation != generation { + tracing::trace!(session = %session_id, generation, current_generation = session.external_caret_refresh_generation, "skipping stale collaboration external caret refresh"); + return; + } + tracing::trace!(session = %session_id, carets = resolved.carets.len(), "refreshing collaboration external carets"); + if session + .editor + .as_ref() + .is_some_and(|current| current == &editor) + { + editor.update(cx, |editor, cx| editor.set_external_carets(resolved.carets, cx)); + } + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "resolving collaboration external carets failed"); + }, + }); + }) + .detach(); + } + + pub(super) fn publish_presence_snapshot(&self) { + if let Some(presence) = &self.presence { + let bytes = presence.encode_self(); + tracing::debug!(session = %self.session, bytes = bytes.len(), "publishing own collaboration presence snapshot"); + self.publish_presence_bytes(bytes); + } else { + tracing::trace!(session = %self.session, "skipping collaboration presence snapshot because store is missing"); + } + } + + pub(super) fn publish_presence_bytes(&self, bytes: Vec) { + if bytes.is_empty() { + tracing::trace!(session = %self.session, "skipping empty collaboration presence publish"); + return; + } + let byte_len = bytes.len(); + if let Err(error) = self + .net_tx + .try_send(flowstate_collab::net::NetCommand::Publish { + session: self.session, + payload: flowstate_collab::net::PublishPayload::Presence(bytes), + }) + { + tracing::warn!(session = %self.session, bytes = byte_len, error = %error, "queueing collaboration presence publish failed"); + } else { + tracing::trace!(session = %self.session, bytes = byte_len, "queued collaboration presence publish"); + } + } + + pub(super) fn refresh_peer_count(&mut self) -> bool { + let peers_present = self.peers_present(); + let session_id = self.session; + if let super::SessionPhase::Attached(attachment) = &mut self.phase { + let previous = attachment.peers_present; + attachment.peers_present = peers_present; + if previous != peers_present { + tracing::info!(session = %session_id, previous, peers_present, "collaboration peer presence count changed"); + return true; + } + } + false + } + + pub(super) fn peers_present(&self) -> usize { + self + .presence + .as_ref() + .map_or(0, |presence| presence.roster().len().saturating_sub(1)) + } + + fn emit_presence_roster_diff(&mut self, before: HashMap, cx: &mut Context) -> bool { + let Some(presence) = &self.presence else { + return false; + }; + let after = remote_roster(presence); + let changed = before != after; + for (key, name) in &after { + if !before.contains_key(key) { + cx.emit(SessionNotice::PeerJoined(name.clone())); + } + } + for (key, name) in before { + if !after.contains_key(&key) { + cx.emit(SessionNotice::PeerLeft(name)); + } + } + changed + } +} + +fn remote_roster(presence: &PresenceStore) -> HashMap { + let self_key = presence.self_key().to_string(); + presence + .roster() + .into_iter() + .filter(|entry| entry.key != self_key) + .map(|entry| (entry.key, entry.name)) + .collect() +} + +fn default_presence_name() -> String { + std::env::var("FLOWSTATE_COLLAB_NAME") + .or_else(|_| std::env::var("USER")) + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "Flowstate user".to_string()) +} diff --git a/crates/flowstate/src/collab/session_timers.rs b/crates/flowstate/src/collab/session_timers.rs new file mode 100644 index 00000000..2fe57afb --- /dev/null +++ b/crates/flowstate/src/collab/session_timers.rs @@ -0,0 +1,409 @@ +use std::time::{Duration, Instant}; + +use anyhow::Result; +use flowstate_collab::{net::NetCommand, presence::PRESENCE_KEEPALIVE_SECS, self_check}; +use gpui::{Context, Timer}; + +use crate::rich_text_element::DocumentProjection; + +use super::{Attachment, CollabSession, Connectivity, DetachReason, SessionNotice, SessionPhase}; + +const ZERO_NEIGHBOR_OFFLINE_GRACE: Duration = Duration::from_secs(5); +const QUIET_DIGEST_ROUNDS_OFFLINE: u8 = 2; +const SELF_CHECK_INTERVAL: Duration = Duration::from_secs(30); +const SELF_CHECK_IDLE: Duration = Duration::from_secs(2); +const RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30); +const RECOVERY_NEIGHBOR_WAIT: Duration = Duration::from_secs(10); + +impl CollabSession { + pub(super) fn attach_timers(&mut self, cx: &mut Context) { + if self.timers_started { + tracing::trace!(session = %self.session, "collaboration timers already started"); + return; + } + self.timers_started = true; + tracing::debug!(session = %self.session, "starting collaboration timers"); + self.spawn_digest_timer(cx); + self.spawn_presence_timer(cx); + self.spawn_self_check_timer(cx); + } + + pub(super) fn note_inbound_traffic(&mut self, cx: &mut Context) { + self.inbound_since_last_digest = true; + self.quiet_digest_rounds = 0; + tracing::trace!(session = %self.session, "noted inbound collaboration network traffic"); + if self.mark_online(cx) { + self.anti_entropy.on_reconnect(); + self.publish_digest(); + } + } + + pub(super) fn mark_online(&mut self, cx: &mut Context) -> bool { + if self + .awaiting_recovery_neighbor_until + .is_some_and(|deadline| self.peers_present() > 0 && self.neighbors.is_empty() && Instant::now() < deadline) + { + return false; + } + + let mut changed = false; + if let SessionPhase::Attached(attachment) = &mut self.phase + && matches!(attachment.connectivity, Connectivity::Offline { .. }) + { + attachment.connectivity = Connectivity::Online; + self.next_recovery_at = None; + self.awaiting_recovery_neighbor_until = None; + self.probe_pending = false; + self.last_probe_failed = false; + changed = true; + } + if changed { + tracing::info!(session = %self.session, "collaboration session marked online"); + cx.notify(); + } + changed + } + + pub(super) fn evaluate_connectivity(&mut self, cx: &mut Context) { + if !matches!(self.phase, SessionPhase::Attached(_)) { + tracing::trace!(session = %self.session, phase = ?self.phase, "skipping collaboration connectivity evaluation for inactive phase"); + return; + } + + self.refresh_peer_count(); + if self.peers_present() == 0 || !self.neighbors.is_empty() { + tracing::trace!(session = %self.session, peers_present = self.peers_present(), neighbors = self.neighbors.len(), "collaboration connectivity considered online"); + self.mark_online(cx); + return; + } + + let now = Instant::now(); + let zero_since = *self.zero_neighbors_since.get_or_insert(now); + let zero_long_enough = now.saturating_duration_since(zero_since) >= ZERO_NEIGHBOR_OFFLINE_GRACE; + let quiet_enough = self.quiet_digest_rounds >= QUIET_DIGEST_ROUNDS_OFFLINE; + tracing::trace!( + session = %self.session, + peers_present = self.peers_present(), + neighbors = self.neighbors.len(), + quiet_rounds = self.quiet_digest_rounds, + quiet_enough, + zero_long_enough, + endpoint_online = self.endpoint_online, + last_probe_failed = self.last_probe_failed, + "evaluating collaboration connectivity", + ); + if zero_long_enough && quiet_enough && (!self.endpoint_online || self.last_probe_failed || !self.start_connectivity_probe(cx)) { + self.mark_offline(now, cx); + } + } + + fn spawn_digest_timer(&mut self, cx: &mut Context) { + let session_id = self.session; + tracing::debug!(session = %session_id, "starting collaboration digest timer"); + cx.spawn(async move |session, cx| { + loop { + let delay = match session.update(cx, |session, _| session.next_digest_delay()) { + Ok(Some(delay)) => delay, + Ok(None) | Err(_) => break, + }; + Timer::after(delay).await; + match session.update(cx, |session, cx| session.run_digest_tick(cx)) { + Ok(true) => {}, + Ok(false) | Err(_) => break, + } + } + tracing::debug!(session = %session_id, "collaboration digest timer stopped"); + }) + .detach(); + } + + fn spawn_presence_timer(&mut self, cx: &mut Context) { + let session_id = self.session; + tracing::debug!(session = %session_id, "starting collaboration presence timer"); + cx.spawn(async move |session, cx| { + loop { + Timer::after(Duration::from_secs(PRESENCE_KEEPALIVE_SECS)).await; + match session.update(cx, |session, cx| session.run_presence_tick(cx)) { + Ok(true) => {}, + Ok(false) | Err(_) => break, + } + } + tracing::debug!(session = %session_id, "collaboration presence timer stopped"); + }) + .detach(); + } + + fn spawn_self_check_timer(&mut self, cx: &mut Context) { + let session_id = self.session; + tracing::debug!(session = %session_id, "starting collaboration self-check timer"); + cx.spawn(async move |session, cx| { + loop { + Timer::after(SELF_CHECK_INTERVAL).await; + match session.update(cx, |session, cx| session.run_self_check_tick(cx)) { + Ok(true) => {}, + Ok(false) | Err(_) => break, + } + } + tracing::debug!(session = %session_id, "collaboration self-check timer stopped"); + }) + .detach(); + } + + fn next_digest_delay(&self) -> Option { + self + .timer_live() + .then(|| self.anti_entropy.duration_until_digest(Instant::now())) + } + + fn run_digest_tick(&mut self, cx: &mut Context) -> bool { + if !self.timer_live() { + return false; + } + + let now = Instant::now(); + if self.anti_entropy.digest_due(now) { + if matches!(self.phase, SessionPhase::Attached(_)) { + tracing::trace!(session = %self.session, quiet_rounds = self.quiet_digest_rounds, inbound_since_last_digest = self.inbound_since_last_digest, "collaboration digest timer fired"); + self.publish_digest(); + self.finish_digest_round(now, cx); + } + self.anti_entropy.mark_digest_sent(now); + } + true + } + + fn run_presence_tick(&mut self, cx: &mut Context) -> bool { + if !self.timer_live() { + return false; + } + if self.presence.is_some() { + tracing::trace!(session = %self.session, "collaboration presence timer fired"); + let roster_changed = self.remove_outdated_presence(cx); + self.refresh_own_presence(cx); + let peer_count_changed = self.refresh_peer_count(); + self.evaluate_connectivity(cx); + self.refresh_external_carets(cx); + if roster_changed || peer_count_changed { + cx.notify(); + } + } + true + } + + fn run_self_check_tick(&mut self, cx: &mut Context) -> bool { + if !self.timer_live() { + return false; + } + if !matches!(self.phase, SessionPhase::Attached(_)) || self.last_document_activity.elapsed() < SELF_CHECK_IDLE { + tracing::trace!(session = %self.session, phase = ?self.phase, idle_for = ?self.last_document_activity.elapsed(), "skipping collaboration self-check tick"); + return true; + } + tracing::trace!(session = %self.session, idle_for = ?self.last_document_activity.elapsed(), "running collaboration self-check tick"); + if let Err(error) = self.run_self_check(cx) { + tracing::error!(session = %self.session, error = %format_args!("{error:#}"), "collaboration self-check failed"); + } + true + } + + fn finish_digest_round(&mut self, now: Instant, cx: &mut Context) { + if self.inbound_since_last_digest { + self.quiet_digest_rounds = 0; + } else { + self.quiet_digest_rounds = self.quiet_digest_rounds.saturating_add(1); + } + self.inbound_since_last_digest = false; + tracing::trace!(session = %self.session, quiet_rounds = self.quiet_digest_rounds, "finished collaboration digest round"); + self.evaluate_connectivity(cx); + self.run_recovery_if_due(now, cx); + } + + fn run_recovery_if_due(&mut self, now: Instant, cx: &mut Context) { + let Some(next_recovery_at) = self.next_recovery_at else { + return; + }; + if now < next_recovery_at { + return; + } + + let mut attempted = false; + if let SessionPhase::Attached(Attachment { + connectivity: Connectivity::Offline { retries, .. }, + .. + }) = &mut self.phase + { + let delay = recovery_delay(*retries); + *retries = retries.saturating_add(1); + self.next_recovery_at = Some(now + delay); + attempted = true; + } + + if attempted { + tracing::warn!(session = %self.session, bootstrap_count = self.bootstrap_addrs.len(), "attempting collaboration connectivity recovery"); + self.awaiting_recovery_neighbor_until = Some(now + RECOVERY_NEIGHBOR_WAIT); + if self.bootstrap_addrs.is_empty() { + if let Err(error) = self.net_tx.try_send(NetCommand::EnsureUp) { + tracing::warn!(session = %self.session, error = %error, "queueing collaboration ensure-up recovery failed"); + } + } else { + if let Err(error) = self.net_tx.try_send(NetCommand::JoinSession { + session: self.session, + bootstrap: self.bootstrap_addrs.clone(), + }) { + tracing::warn!(session = %self.session, error = %error, "queueing collaboration join-session recovery failed"); + } + } + self.publish_digest(); + cx.notify(); + } + } + + fn run_self_check(&mut self, cx: &mut Context) -> Result<()> { + let Some(runtime) = self.runtime.clone() else { + return Ok(()); + }; + let Some(editor) = self.editor.clone() else { + return Ok(()); + }; + + let live_document = editor.read(cx).document().clone(); + let live_hash = self_check::projection_hash(&live_document); + let current_vv = self.runtime_vv.clone(); + // Cheap path: if neither the Loro state (version vector) nor the live + // projection hash changed since the last verified check, there can be no new + // drift, so skip the full reprojection. + if self + .last_self_check + .as_ref() + .is_some_and(|(vv, hash)| *vv == current_vv && *hash == live_hash) + { + tracing::trace!(session = %self.session, "collaboration projection self-check skipped (unchanged)"); + return Ok(()); + } + + let session_id = self.session; + cx.spawn(async move |session, cx| { + let projected = runtime.projection_snapshot().await; + let _ = session.update(cx, |session, cx| match projected { + Ok(mut projected) => { + projected.assets = live_document.assets.clone(); + let projected_hash = self_check::projection_hash(&projected); + if live_hash == projected_hash { + session.last_self_check = Some((current_vv, live_hash)); + tracing::trace!(session = %session_id, live_hash = %format_args!("{live_hash:016x}"), "collaboration projection self-check passed"); + } else { + tracing::error!( + session = %session_id, + live_hash = %format_args!("{live_hash:016x}"), + projected_hash = %format_args!("{projected_hash:016x}"), + "collaboration projection drift detected", + ); + if let Err(error) = session.rebuild_from_projection(projected, cx) { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "rebuilding drifted collaboration projection failed"); + } + } + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "collaboration projection self-check request failed"); + }, + }); + }) + .detach(); + Ok(()) + } + + fn rebuild_from_projection(&mut self, projected: DocumentProjection, cx: &mut Context) -> Result<()> { + let Some(editor) = self.editor.clone() else { + return Ok(()); + }; + tracing::warn!(session = %self.session, paragraphs = projected.paragraphs.len(), blocks = projected.blocks.len(), "rebuilding editor document from collaboration projection"); + editor.update(cx, |editor, cx| editor.replace_document_projection(projected, cx)); + self.last_document_activity = Instant::now(); + self.refresh_external_carets(cx); + tracing::info!(session = %self.session, "rebuilt editor document from collaboration projection"); + cx.emit(SessionNotice::ViewRebuilt); + Ok(()) + } + + fn start_connectivity_probe(&mut self, cx: &mut Context) -> bool { + if self.probe_pending { + return true; + } + if self.runtime.is_none() { + return false; + } + let candidates = self.known_peers.iter().copied().collect::>(); + if candidates.is_empty() { + return false; + } + let our_vv = self.runtime_vv.clone(); + let (reply_tx, reply_rx) = async_channel::bounded(1); + if self + .net_tx + .try_send(NetCommand::PullUpdates { + session: self.session, + candidates, + our_vv, + reply: reply_tx, + }) + .is_err() + { + return false; + } + + self.probe_pending = true; + cx.spawn(async move |session, cx| { + let result = reply_rx.recv().await; + let _ = session.update(cx, |session, cx| { + session.probe_pending = false; + match result { + Ok(Ok(bytes)) => { + session.last_probe_failed = false; + if !bytes.is_empty() + && let Err(error) = session.import_update_bytes(&bytes, cx) + { + session.detach( + DetachReason::Fatal(format!("probing collaboration peer failed to apply updates: {error:#}")), + cx, + ); + return; + } + session.note_inbound_traffic(cx); + }, + Ok(Err(_)) | Err(_) => { + session.last_probe_failed = true; + session.evaluate_connectivity(cx); + }, + } + }); + }) + .detach(); + true + } + + fn mark_offline(&mut self, now: Instant, cx: &mut Context) { + let session_id = self.session; + let quiet_rounds = self.quiet_digest_rounds; + let neighbors = self.neighbors.len(); + if let SessionPhase::Attached(attachment) = &mut self.phase + && matches!(attachment.connectivity, Connectivity::Online) + { + let peers_present = attachment.peers_present; + attachment.connectivity = Connectivity::Offline { since: now, retries: 0 }; + self.next_recovery_at = Some(now); + tracing::warn!(session = %session_id, quiet_rounds, neighbors, peers_present, "collaboration session marked offline"); + cx.notify(); + } + } + + fn timer_live(&self) -> bool { + !matches!(self.phase, SessionPhase::Detached(_)) + } +} + +fn recovery_delay(retries: u32) -> Duration { + let shift = retries.min(5); + let secs = 1_u64 + .checked_shl(shift) + .unwrap_or(RECOVERY_MAX_BACKOFF.as_secs()); + Duration::from_secs(secs).min(RECOVERY_MAX_BACKOFF) +} diff --git a/crates/flowstate/src/collab/share_dialog.rs b/crates/flowstate/src/collab/share_dialog.rs new file mode 100644 index 00000000..b055987f --- /dev/null +++ b/crates/flowstate/src/collab/share_dialog.rs @@ -0,0 +1,406 @@ +use flowstate_collab::{SessionId, ticket::SessionTicket}; +use gpui::{ + AnyElement, App, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, SharedString, + Subscription, WeakEntity, Window, div, prelude::*, px, +}; +use gpui_component::{ + Disableable, WindowExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + scroll::ScrollableElement, + v_flex, +}; +use uuid::Uuid; + +use crate::workspace::Workspace; + +use super::{DetachReason, SessionPhase, status}; + +#[path = "share_dialog_view.rs"] +mod share_dialog_view; +use share_dialog_view::*; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CollabDialogMode { + Share, + Join, +} + +pub struct CollabShareDialog { + workspace: WeakEntity, + panel_id: Option, + mode: CollabDialogMode, + join_input: Entity, + joining_session: Option, + ticket_text: Option, + ticket_loading: bool, + ticket_error: Option, + copy_notice: Option, + join_error: Option, + _input_subscription: Subscription, + _join_subscription: Option, +} + +#[hotpath::measure_all] +impl CollabShareDialog { + pub fn new( + workspace: WeakEntity, + panel_id: Option, + mode: CollabDialogMode, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let join_input = cx.new(|cx| InputState::new(window, cx).placeholder("Paste a Flowstate collaboration invite")); + let input_subscription = cx.subscribe(&join_input, |dialog, _, event: &InputEvent, cx| { + if let InputEvent::Change = event { + dialog.joining_session = None; + dialog._join_subscription = None; + dialog.validate_join_ticket(cx); + } + }); + + let mut dialog = Self { + workspace, + panel_id, + mode, + join_input, + joining_session: None, + ticket_text: None, + ticket_loading: false, + ticket_error: None, + copy_notice: None, + join_error: None, + _input_subscription: input_subscription, + _join_subscription: None, + }; + if mode == CollabDialogMode::Share { + dialog.refresh_ticket(cx); + } + dialog + } + + pub fn focus(&self, window: &mut Window, cx: &mut Context) { + if self.mode == CollabDialogMode::Join { + self.join_input.focus_handle(cx).focus(window); + } + } + + fn close(&mut self, window: &mut Window, cx: &mut Context) { + let _ = self + .workspace + .update(cx, |workspace, cx| workspace.close_collaboration_dialog(cx)); + window.close_dialog(cx); + } + + fn set_mode(&mut self, mode: CollabDialogMode, cx: &mut Context) { + if self.mode == mode { + return; + } + self.mode = mode; + self.join_error = None; + self.copy_notice = None; + if mode == CollabDialogMode::Share { + self.refresh_ticket(cx); + } + cx.notify(); + } + + fn start_session(&mut self, cx: &mut Context) { + let Some(panel_id) = self.panel_id else { + self.ticket_error = Some("Open a document before starting a session.".into()); + cx.notify(); + return; + }; + + match self + .workspace + .update(cx, |workspace, cx| workspace.start_collaboration_on_document(panel_id, cx)) + { + Ok(Some(_)) => self.refresh_ticket(cx), + Ok(None) => { + self.ticket_error = Some("The active document could not be shared.".into()); + cx.notify(); + }, + Err(error) => { + self.ticket_error = Some(format!("The workspace is no longer available: {error}").into()); + cx.notify(); + }, + } + } + + fn refresh_ticket(&mut self, cx: &mut Context) { + let Some(panel_id) = self.panel_id else { + self.ticket_text = None; + self.ticket_loading = false; + return; + }; + let Some(ticket_rx) = crate::collab::request_ticket_for_panel(panel_id, cx) else { + self.ticket_text = None; + self.ticket_loading = false; + self.ticket_error = None; + return; + }; + + self.ticket_loading = true; + self.ticket_error = None; + self.copy_notice = None; + cx.notify(); + + cx.spawn(async move |dialog, cx| { + let result = ticket_rx.recv().await; + let encoded = match result { + Ok(Ok(ticket)) => Ok(ticket.encode_text()), + Ok(Err(error)) => Err(format!("Creating collaboration invite failed: {error:#}")), + Err(error) => Err(format!("Creating collaboration invite failed: {error}")), + }; + + let _ = dialog.update(cx, |dialog, cx| { + dialog.ticket_loading = false; + match encoded { + Ok(text) => { + dialog.ticket_text = Some(text.into()); + dialog.ticket_error = None; + }, + Err(error) => { + dialog.ticket_error = Some(error.into()); + }, + } + cx.notify(); + }); + }) + .detach(); + } + + fn validate_join_ticket(&mut self, cx: &mut Context) { + self.join_error = match self.parse_join_ticket(cx) { + Ok(_) => None, + Err(error) => Some(error.into()), + }; + cx.notify(); + } + + fn parse_join_ticket(&self, cx: &App) -> Result, String> { + let text = self.join_input.read(cx).value().trim().to_string(); + if text.is_empty() { + return Ok(None); + } + SessionTicket::decode_text(&text) + .map(Some) + .map_err(|error| format!("Invalid invite: {error}")) + } + + fn join_session(&mut self, window: &mut Window, cx: &mut Context) { + if self.joining_session.is_some() { + return; + } + let ticket = match self.parse_join_ticket(cx) { + Ok(Some(ticket)) => ticket, + Ok(None) => { + self.join_error = Some("Paste an invite before joining.".into()); + cx.notify(); + return; + }, + Err(error) => { + self.join_error = Some(error.into()); + cx.notify(); + return; + }, + }; + + match self + .workspace + .update(cx, |workspace, cx| workspace.join_collaboration_session(ticket, window, cx)) + { + Ok(Some(session)) => { + self.join_error = None; + self.joining_session = Some(session); + self.subscribe_join_session(session, cx); + cx.notify(); + }, + Ok(None) => { + self.join_error = Some("Joining collaboration session failed.".into()); + cx.notify(); + }, + Err(error) => { + self.join_error = Some(format!("The workspace is no longer available: {error}").into()); + cx.notify(); + }, + } + } + + fn subscribe_join_session(&mut self, session_id: SessionId, cx: &mut Context) { + self._join_subscription = crate::collab::session_for_id(session_id, cx).map(|session| cx.observe(&session, |_, _, cx| cx.notify())); + } + + fn confirm_leave(&mut self, window: &mut Window, cx: &mut Context) { + let Some(panel_id) = self.panel_id else { + return; + }; + let _ = self + .workspace + .update(cx, |workspace, cx| workspace.confirm_leave_collaboration_on_panel(panel_id, window, cx)); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + match event.keystroke.key.as_str() { + "escape" => { + self.close(window, cx); + cx.stop_propagation(); + }, + "enter" if self.mode == CollabDialogMode::Join => { + self.join_session(window, cx); + cx.stop_propagation(); + }, + _ => {}, + } + } + + fn render_share_pane(&self, phase: Option<&SessionPhase>, cx: &mut Context) -> AnyElement { + if self.panel_id.is_none() { + return v_flex() + .gap_3() + .child(section_title("Share this document", cx)) + .child(helper_text("Open a rich-text document before starting a collaboration session.", cx)) + .into_any_element(); + } + + if phase.is_none_or(|phase| matches!(phase, SessionPhase::Detached(_))) { + return v_flex() + .gap_3() + .child(section_title("Share this document", cx)) + .child(helper_text( + "Start a session to create one invite ticket. Everyone who joins can edit, invite others, and leave independently.", + cx, + )) + .child( + Button::new("start-collaboration-session") + .label("Start session") + .primary() + .on_click(cx.listener(|dialog, _, _, cx| dialog.start_session(cx))), + ) + .when_some(self.ticket_error.clone(), |this, error| this.child(error_text(error, cx))) + .into_any_element(); + } + + let Some(phase) = phase else { + return v_flex().into_any_element(); + }; + let roster = self + .panel_id + .map_or_else(Vec::new, |panel_id| crate::collab::roster_for_panel(panel_id, cx)); + v_flex() + .gap_3() + .child( + h_flex() + .items_center() + .justify_between() + .child(section_title("Invite people", cx)) + .child(status::status_pill(phase, cx)), + ) + .child(helper_text(connectivity_text(phase).as_str(), cx)) + .child(roster_list(roster, cx)) + .child(ticket_box(self.ticket_text.clone(), self.ticket_loading, cx)) + .when_some(self.ticket_error.clone(), |this, error| this.child(error_text(error, cx))) + .when_some(self.copy_notice.clone(), |this, notice| this.child(success_text(notice, cx))) + .child( + h_flex().justify_end().child( + Button::new("leave-collaboration-session") + .label("Leave session") + .danger() + .on_click(cx.listener(|dialog, _, window, cx| dialog.confirm_leave(window, cx))), + ), + ) + .into_any_element() + } + + fn render_join_pane(&self, cx: &mut Context) -> AnyElement { + let parsed = self.parse_join_ticket(cx).ok().flatten(); + let progress = self.join_progress(cx); + v_flex() + .gap_3() + .child(section_title("Join a session", cx)) + .child(helper_text( + "Paste an invite ticket. Joining opens the shared document as a new tab and never merges into your existing files.", + cx, + )) + .child(Input::new(&self.join_input).w_full().cleanable(true)) + .when_some(parsed.as_ref().map(|ticket| ticket.title.clone()), |this, title| { + this.child(success_text(format!("Invite for: {title}").into(), cx)) + }) + .when_some(progress, |this, (is_error, text)| { + if is_error { + this.child(error_text(text, cx)) + } else { + this.child(progress_text(text, cx)) + } + }) + .when_some(self.join_error.clone(), |this, error| this.child(error_text(error, cx))) + .child( + h_flex().justify_end().child( + Button::new("join-collaboration-session") + .label(if self.joining_session.is_some() { "Joining..." } else { "Join" }) + .primary() + .disabled(self.joining_session.is_some()) + .on_click(cx.listener(|dialog, _, window, cx| dialog.join_session(window, cx))), + ), + ) + .into_any_element() + } + + fn join_progress(&self, cx: &App) -> Option<(bool, SharedString)> { + let phase = crate::collab::phase_for_session(self.joining_session?, cx)?; + match phase { + SessionPhase::Creating => Some((false, "Starting collaboration...".into())), + SessionPhase::Joining(stage) => Some((false, status::join_stage_label(&stage).into())), + SessionPhase::Attached(_) => Some((false, "Joined. Opening shared document...".into())), + SessionPhase::Detached(DetachReason::JoinFailed(error)) => Some((true, error.into())), + SessionPhase::Detached(_) => None, + } + } +} + +#[hotpath::measure_all] +impl Focusable for CollabShareDialog { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.join_input.focus_handle(cx) + } +} + +#[hotpath::measure_all] +impl Render for CollabShareDialog { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let phase = self + .panel_id + .and_then(|panel_id| crate::collab::phase_for_panel(panel_id, cx)); + + v_flex() + .max_h(px(560.0)) + .gap_4() + .on_key_down(cx.listener(Self::on_key_down)) + .child( + h_flex() + .gap_2() + .child( + tab_button("collab-share-tab", "Share", self.mode == CollabDialogMode::Share).on_click(cx.listener(|dialog, _, _, cx| { + dialog.set_mode(CollabDialogMode::Share, cx); + })), + ) + .child( + tab_button("collab-join-tab", "Join", self.mode == CollabDialogMode::Join).on_click(cx.listener(|dialog, _, window, cx| { + dialog.set_mode(CollabDialogMode::Join, cx); + dialog.focus(window, cx); + })), + ), + ) + .child( + div() + .flex_1() + .overflow_y_scrollbar() + .child(match self.mode { + CollabDialogMode::Share => self.render_share_pane(phase.as_ref(), cx), + CollabDialogMode::Join => self.render_join_pane(cx), + }), + ) + } +} diff --git a/crates/flowstate/src/collab/share_dialog_view.rs b/crates/flowstate/src/collab/share_dialog_view.rs new file mode 100644 index 00000000..93855b77 --- /dev/null +++ b/crates/flowstate/src/collab/share_dialog_view.rs @@ -0,0 +1,174 @@ +use gpui::{AnyElement, App, Context, IntoElement, ParentElement, SharedString, div, prelude::*, px, relative, rgb}; +use gpui_component::{ + ActiveTheme as _, Disableable, Sizable, + avatar::Avatar, + button::{Button, ButtonVariants as _}, + clipboard::Clipboard, + h_flex, + scroll::ScrollableElement, + v_flex, +}; + +use super::CollabShareDialog; +use crate::collab::{Connectivity, SessionPhase}; + +pub(super) fn tab_button(id: &'static str, label: &'static str, selected: bool) -> Button { + let button = Button::new(id).label(label).small(); + if selected { button.primary() } else { button.outline() } +} + +pub(super) fn section_title(title: &'static str, cx: &App) -> impl IntoElement { + div() + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(title) +} + +pub(super) fn helper_text(text: &str, cx: &App) -> impl IntoElement { + div() + .text_sm() + .line_height(relative(1.45)) + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) +} + +pub(super) fn ticket_box(ticket: Option, loading: bool, cx: &mut Context) -> AnyElement { + h_flex() + .items_start() + .gap_2() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .p_3() + .child( + div() + .flex_1() + .min_h(px(84.0)) + .max_h(px(132.0)) + .overflow_y_scrollbar() + .text_xs() + .line_height(relative(1.35)) + .text_color(cx.theme().foreground) + .child(if loading { + SharedString::from("Minting a fresh invite...") + } else { + ticket + .clone() + .unwrap_or_else(|| "Start sharing to mint an invite.".into()) + }), + ) + .child(copy_invite_button(ticket, loading, cx)) + .into_any_element() +} + +pub(super) fn error_text(text: SharedString, cx: &App) -> impl IntoElement { + div().text_sm().text_color(cx.theme().danger).child(text) +} + +pub(super) fn progress_text(text: SharedString, cx: &App) -> impl IntoElement { + div().text_sm().text_color(cx.theme().info).child(text) +} + +pub(super) fn success_text(text: SharedString, cx: &App) -> impl IntoElement { + div().text_sm().text_color(cx.theme().success).child(text) +} + +pub(super) fn roster_list(entries: Vec, cx: &App) -> AnyElement { + let mut list = v_flex() + .gap_2() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .p_3() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Participants"), + ); + + if entries.is_empty() { + return list + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Presence is starting..."), + ) + .into_any_element(); + } + + for entry in entries { + let name = entry.name; + let label = if entry.is_self { format!("{name} (you)") } else { name.clone() }; + let color = gpui::Hsla::from(rgb(entry.color_rgb)); + list = list.child( + h_flex() + .items_center() + .gap_2() + .child( + Avatar::new() + .name(name) + .small() + .bg(color.opacity(0.14)) + .text_color(color) + .border_color(color.opacity(0.32)), + ) + .child(div().size(px(8.0)).rounded_full().bg(color)) + .child( + div() + .text_sm() + .text_color(cx.theme().foreground) + .child(label), + ), + ); + } + list.into_any_element() +} + +pub(super) fn connectivity_text(phase: &SessionPhase) -> String { + match phase { + SessionPhase::Creating => "Starting collaboration...".to_string(), + SessionPhase::Joining(_) => "Joining collaboration...".to_string(), + SessionPhase::Attached(attachment) => match &attachment.connectivity { + Connectivity::Online if attachment.peers_present == 0 => "Only you - share the invite.".to_string(), + Connectivity::Online => format!("{} people in session.", attachment.peers_present + 1), + Connectivity::Offline { .. } => "Offline - reconnecting. Changes will sync when connected.".to_string(), + }, + SessionPhase::Detached(_) => "This document is local.".to_string(), + } +} + +fn copy_invite_button(ticket: Option, loading: bool, cx: &mut Context) -> AnyElement { + let Some(ticket) = ticket.filter(|_| !loading) else { + return Button::new("copy-collaboration-invite-disabled") + .label("Copy") + .small() + .disabled(true) + .into_any_element(); + }; + let dialog = cx.entity().downgrade(); + h_flex() + .items_center() + .gap_1() + .child( + Clipboard::new("copy-collaboration-invite") + .value(ticket) + .on_copied(move |_, _, cx| { + let _ = dialog.update(cx, |dialog, cx| { + dialog.copy_notice = Some("Invite copied to clipboard.".into()); + cx.notify(); + }); + }), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Copy"), + ) + .into_any_element() +} diff --git a/crates/flowstate/src/collab/shutdown.rs b/crates/flowstate/src/collab/shutdown.rs new file mode 100644 index 00000000..8dd1923f --- /dev/null +++ b/crates/flowstate/src/collab/shutdown.rs @@ -0,0 +1,10 @@ +use gpui::BorrowAppContext; + +use super::CollabManager; + +pub fn shutdown(cx: &mut C) +where + C: BorrowAppContext, +{ + cx.update_default_global::(|manager, _| manager.shutdown_runtime()); +} diff --git a/crates/flowstate/src/collab/status.rs b/crates/flowstate/src/collab/status.rs new file mode 100644 index 00000000..b02d2a8a --- /dev/null +++ b/crates/flowstate/src/collab/status.rs @@ -0,0 +1,87 @@ +use gpui::{AnyElement, App, IntoElement, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, h_flex}; + +use super::{Attachment, Connectivity, JoinStage, SessionPhase}; + +pub fn status_pill(phase: &SessionPhase, cx: &App) -> AnyElement { + let (label, color) = status_label_and_color(phase, cx); + h_flex() + .flex_none() + .items_center() + .gap_1() + .px_2() + .py_0p5() + .rounded_full() + .border_1() + .border_color(color.opacity(0.38)) + .bg(color.opacity(0.10)) + .child(div().size(px(6.0)).rounded_full().bg(color)) + .child(div().text_size(px(10.0)).text_color(color).child(label)) + .into_any_element() +} + +pub fn tab_badge(phase: &SessionPhase, cx: &App) -> Option { + let (count, color) = match phase { + SessionPhase::Attached(attachment) => { + let color = match &attachment.connectivity { + Connectivity::Online => cx.theme().success, + Connectivity::Offline { .. } => cx.theme().muted_foreground, + }; + (Some(attachment.peers_present + 1), color) + }, + SessionPhase::Creating | SessionPhase::Joining(_) => (None, cx.theme().info), + SessionPhase::Detached(_) => return None, + }; + + Some( + h_flex() + .items_center() + .gap_0p5() + .child(div().size(px(6.0)).rounded_full().bg(color)) + .when_some(count, |this, count| { + this.child( + div() + .text_size(px(9.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(color) + .child(count.to_string()), + ) + }) + .into_any_element(), + ) +} + +fn status_label_and_color(phase: &SessionPhase, cx: &App) -> (String, gpui::Hsla) { + match phase { + SessionPhase::Creating => ("Starting share".to_string(), cx.theme().info), + SessionPhase::Joining(stage) => (join_stage_label(stage), cx.theme().info), + SessionPhase::Attached(attachment) => attached_label_and_color(attachment, cx), + SessionPhase::Detached(_) => ("Not shared".to_string(), cx.theme().muted_foreground), + } +} + +pub fn join_stage_label(stage: &JoinStage) -> String { + match stage { + JoinStage::Resolving => "Resolving invite".to_string(), + JoinStage::Subscribing => "Joining session".to_string(), + JoinStage::FetchingSnapshot { got, total } => match total { + Some(total) => format!("Fetching {got}/{total} bytes"), + None => "Fetching document".to_string(), + }, + JoinStage::Building => "Opening shared doc".to_string(), + } +} + +fn attached_label_and_color(attachment: &Attachment, cx: &App) -> (String, gpui::Hsla) { + match &attachment.connectivity { + Connectivity::Online => { + let participants = attachment.peers_present + 1; + if attachment.peers_present == 0 { + ("Only you".to_string(), cx.theme().success) + } else { + (format!("{participants} in session"), cx.theme().success) + } + }, + Connectivity::Offline { .. } => ("Offline - will sync".to_string(), cx.theme().warning), + } +} diff --git a/crates/flowstate/src/commands.rs b/crates/flowstate/src/commands.rs index a629516d..f7a8b887 100644 --- a/crates/flowstate/src/commands.rs +++ b/crates/flowstate/src/commands.rs @@ -76,6 +76,12 @@ pub enum CommandId { OpenDocument, OpenDemoDocument, CloseDocument, + ShareDocument, + JoinSession, + StartCollaboration, + CopyCollaborationTicket, + JoinCollaborationFromClipboard, + LeaveCollaboration, FindInDocument, ToggleRibbon, NextTab, @@ -201,6 +207,12 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::OpenDocument, "Open Document", APP, &[]), CommandSpec::new(CommandId::OpenDemoDocument, "Open Demo Document", APP, &[]), CommandSpec::new(CommandId::CloseDocument, "Close Document", APP, &[]), + CommandSpec::new(CommandId::ShareDocument, "Share / Collaborate...", APP, &[]), + CommandSpec::new(CommandId::JoinSession, "Join Collaboration Session...", APP, &[]), + CommandSpec::new(CommandId::StartCollaboration, "Start Collaboration", APP, &[]), + CommandSpec::new(CommandId::CopyCollaborationTicket, "Copy Collaboration Invite", APP, &[]), + CommandSpec::new(CommandId::JoinCollaborationFromClipboard, "Join Collaboration from Clipboard", APP, &[]), + CommandSpec::new(CommandId::LeaveCollaboration, "Leave Collaboration", APP, &[]), CommandSpec::new(CommandId::FindInDocument, "Find in Document", APP, &["cmd-f", "ctrl-f"]), CommandSpec::new(CommandId::ToggleRibbon, "Toggle Ribbon", APP, &[]), CommandSpec::new(CommandId::NextTab, "Next Tab", APP, &["ctrl-tab"]), diff --git a/crates/flowstate/src/commands/keymap.rs b/crates/flowstate/src/commands/keymap.rs index 4de74ee0..4e69f6ea 100644 --- a/crates/flowstate/src/commands/keymap.rs +++ b/crates/flowstate/src/commands/keymap.rs @@ -151,6 +151,12 @@ fn action_for_command(command: CommandId) -> Option> { | CommandId::OpenDocument | CommandId::OpenDemoDocument | CommandId::CloseDocument + | CommandId::ShareDocument + | CommandId::JoinSession + | CommandId::StartCollaboration + | CommandId::CopyCollaborationTicket + | CommandId::JoinCollaborationFromClipboard + | CommandId::LeaveCollaboration | CommandId::ToggleRibbon | CommandId::NextTab | CommandId::PreviousTab diff --git a/crates/flowstate/src/docx_conversion/mod.rs b/crates/flowstate/src/docx_conversion/mod.rs index 99640b6d..39761b87 100644 --- a/crates/flowstate/src/docx_conversion/mod.rs +++ b/crates/flowstate/src/docx_conversion/mod.rs @@ -3,7 +3,8 @@ use std::{io, path::Path}; pub use flowstate_docx::{ CleanAction, CleanedDocx, DocxCleanReport, DocxCleanStats, DocxConversionReport, RecognitionRule, clean_docx_bytes, convert_cleaned_docx_to_document, convert_docx_bytes_to_document, convert_docx_to_db8, convert_docx_to_document, convert_docx_to_pdf, - convert_pdf_to_db8, embed_db8_file_in_pdf, extract_db8_bytes_from_pdf, write_docx, write_pdf, write_pdf_with_db8_bytes, + convert_pdf_to_db8, embed_db8_file_in_pdf, extract_db8_bytes_from_pdf, import_cleaned_docx_to_loro, import_docx_bytes_to_loro, + import_docx_to_loro, write_docx, write_pdf, write_pdf_with_db8_bytes, }; use crate::app_settings::load_document_theme; diff --git a/crates/flowstate/src/lib.rs b/crates/flowstate/src/lib.rs index 1b6957bd..c422958d 100644 --- a/crates/flowstate/src/lib.rs +++ b/crates/flowstate/src/lib.rs @@ -6,10 +6,12 @@ pub mod app; pub mod app_settings; +pub mod collab; pub mod commands; pub mod docx_conversion; pub mod file_search; pub mod flow; +pub mod logging; pub mod ribbon; pub mod rich_text_element; pub mod workspace; diff --git a/crates/flowstate/src/logging.rs b/crates/flowstate/src/logging.rs new file mode 100644 index 00000000..c0d27ec0 --- /dev/null +++ b/crates/flowstate/src/logging.rs @@ -0,0 +1,188 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use anyhow::{Context as _, Result}; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _}; + +use crate::app_settings::flowstate_data_dir; + +/// Default filter: only the most serious events (errors), for every target. +/// Raise Flowstate's own verbosity with `FLOWSTATE_LOG_LEVEL`, or take full +/// control (including dependencies) with `FLOWSTATE_LOG` / `RUST_LOG`. +const DEFAULT_LOG_FILTER: &str = "error"; + +/// Crates whose verbosity `FLOWSTATE_LOG_LEVEL` raises. Dependencies stay at the +/// default level so the output stays focused on Flowstate's own logs. +const FLOWSTATE_TARGETS: [&str; 3] = ["flowstate", "flowstate_collab", "gpui_flowtext"]; + +pub struct LoggingGuard { + _guard: WorkerGuard, + directory: PathBuf, +} + +impl LoggingGuard { + pub fn directory(&self) -> &Path { + &self.directory + } +} +pub fn init() -> Result { + let directory = log_directory(); + fs::create_dir_all(&directory).with_context(|| format!("creating log directory {} failed", directory.display()))?; + prune_log_files(&directory)?; + let file_appender = tracing_appender::rolling::daily(&directory, "flowstate.log"); + let (writer, guard) = tracing_appender::non_blocking(file_appender); + + // Optionally mirror logs to stdout (handy when running from a terminal). + let stdout_layer = log_to_stdout().then(|| { + fmt::layer() + .with_writer(std::io::stdout) + .with_ansi(true) + .with_file(true) + .with_line_number(true) + .with_target(true) + }); + + tracing_subscriber::registry() + .with(env_filter()) + .with( + fmt::layer() + .with_writer(writer) + .with_ansi(false) + .with_file(true) + .with_line_number(true) + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true), + ) + .with(stdout_layer) + .try_init() + .context("initializing flowstate logging failed")?; + + Ok(LoggingGuard { _guard: guard, directory }) +} + +/// Resolves the log filter from the environment, in precedence order: +/// 1. `FLOWSTATE_LOG` — full [`EnvFilter`] directive (advanced; also controls +/// dependency crates), e.g. `flowstate_collab=trace,flowstate::collab=debug`. +/// 2. `RUST_LOG` — the standard [`EnvFilter`] directive. +/// 3. `FLOWSTATE_LOG_LEVEL` — a single level (`error|warn|info|debug|trace`) +/// applied to Flowstate's own crates, leaving dependencies at `error`. +/// 4. Default — `error` only. +fn env_filter() -> EnvFilter { + let Some(directive) = directive_from_env() else { + return EnvFilter::new(DEFAULT_LOG_FILTER); + }; + EnvFilter::try_new(&directive).unwrap_or_else(|error| { + eprintln!("invalid log filter {directive:?}: {error}; falling back to {DEFAULT_LOG_FILTER:?}"); + EnvFilter::new(DEFAULT_LOG_FILTER) + }) +} + +fn directive_from_env() -> Option { + for var in ["FLOWSTATE_LOG", "RUST_LOG"] { + if let Ok(filter) = env::var(var) + && !filter.trim().is_empty() + { + return Some(filter); + } + } + + let level = level_from_env()?; + let scoped = FLOWSTATE_TARGETS + .iter() + .map(|target| format!("{target}={level}")) + .collect::>() + .join(","); + Some(format!("{DEFAULT_LOG_FILTER},{scoped}")) +} + +fn level_from_env() -> Option<&'static str> { + let value = env::var("FLOWSTATE_LOG_LEVEL").ok()?; + match value.trim().to_ascii_lowercase().as_str() { + "" => None, + "error" => Some("error"), + "warn" | "warning" => Some("warn"), + "info" => Some("info"), + "debug" => Some("debug"), + "trace" => Some("trace"), + other => { + eprintln!("invalid FLOWSTATE_LOG_LEVEL {other:?}; expected error|warn|info|debug|trace"); + None + }, + } +} + +fn log_to_stdout() -> bool { + env::var("FLOWSTATE_LOG_STDOUT").is_ok_and(|value| is_truthy(&value)) +} + +fn is_truthy(value: &str) -> bool { + matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on") +} + +fn log_directory() -> PathBuf { + env::var_os("FLOWSTATE_LOG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| flowstate_data_dir().join("logs")) +} +const MAX_LOG_FILES: usize = 14; +const MAX_LOG_AGE: Duration = Duration::from_hours(336); + +fn prune_log_files(directory: &Path) -> Result<()> { + let mut candidates = Vec::new(); + let now = SystemTime::now(); + for entry in fs::read_dir(directory).with_context(|| format!("reading log directory {} failed", directory.display()))? { + let entry = entry?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("flowstate.log.") { + continue; + } + let metadata = entry.metadata()?; + let modified = metadata.modified().ok(); + let age = modified.and_then(|modified| now.duration_since(modified).ok()); + if age.is_some_and(|age| age > MAX_LOG_AGE) { + let _ = fs::remove_file(&path); + continue; + } + candidates.push((path, modified.unwrap_or(SystemTime::UNIX_EPOCH))); + } + + if candidates.len() > MAX_LOG_FILES { + candidates.sort_by_key(|(_, modified)| *modified); + for (path, _) in candidates.drain(..candidates.len() - MAX_LOG_FILES) { + let _ = fs::remove_file(path); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prunes_excess_log_files() { + let dir = tempfile::tempdir().unwrap(); + for index in 0..20 { + let path = dir.path().join(format!("flowstate.log.{index}")); + fs::write(&path, index.to_string()).unwrap(); + } + + prune_log_files(dir.path()).unwrap(); + + let retained = fs::read_dir(dir.path()) + .unwrap() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name()) + .filter(|name| name.to_string_lossy().starts_with("flowstate.log.")) + .count(); + assert!(retained <= MAX_LOG_FILES); + } +} diff --git a/crates/flowstate/src/main.rs b/crates/flowstate/src/main.rs index 2af3b150..670211cd 100644 --- a/crates/flowstate/src/main.rs +++ b/crates/flowstate/src/main.rs @@ -7,7 +7,7 @@ use clap::{Parser, Subcommand}; use flowstate::{ docx_conversion::{convert_db8_to_docx, convert_db8_to_pdf, convert_docx_to_pdf, convert_pdf_to_db8}, - run_standalone, write_demo_document, + logging, run_standalone, write_demo_document, }; struct FlowstateAllocator; @@ -94,6 +94,14 @@ enum CliCommand { #[hotpath::main(allocator = FlowstateAllocator)] fn main() { + let _logging_guard = match logging::init() { + Ok(guard) => Some(guard), + Err(error) => { + eprintln!("flowstate logging initialization failed: {error:#}"); + None + }, + }; + let cli = Cli::parse(); if cli.write_demo_db8 { diff --git a/crates/flowstate/src/ribbon/editor_ribbon/command_dispatch.rs b/crates/flowstate/src/ribbon/editor_ribbon/command_dispatch.rs index a0fca768..10200aaf 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/command_dispatch.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/command_dispatch.rs @@ -65,7 +65,7 @@ fn perform_ribbon_command(editor: &mut RichTextEditor, command_id: RibbonCommand RibbonCommandId::Redo => { editor.redo(cx); }, - RibbonCommandId::ExportFormat | RibbonCommandId::ExportSend | RibbonCommandId::ToggleInvisibility => {}, + RibbonCommandId::Revisions | RibbonCommandId::ExportFormat | RibbonCommandId::ExportSend | RibbonCommandId::ToggleInvisibility => {}, } } diff --git a/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs b/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs index 5c825d68..1bea8ccc 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs @@ -141,6 +141,7 @@ fn ribbon_command_key(command_id: RibbonCommandId) -> u64 { RibbonCommandId::ClearFormatting => 5_003, RibbonCommandId::Undo => 6_000, RibbonCommandId::Redo => 6_001, + RibbonCommandId::Revisions => 6_002, RibbonCommandId::ExportFormat => 7_000, RibbonCommandId::ExportSend => 7_001, RibbonCommandId::ToggleInvisibility => 8_000, diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_impl.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_impl.rs index d99aa3a0..70311bff 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_impl.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_impl.rs @@ -87,6 +87,19 @@ impl ModernStylesRibbon { overflow_behavior: OverflowBehavior::KeepVisible, checked_highlight: None, }, + RibbonCommand { + id: RibbonCommandId::Revisions, + label: "Revisions", + group_id: "history", + shortcut: None, + command_id: None, + priority: 2, + accent: None, + selected: false, + disabled: panel_id.is_none(), + overflow_behavior: OverflowBehavior::KeepVisible, + checked_highlight: None, + }, ], }, ); diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs index dfb09e67..32db4419 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs @@ -127,7 +127,9 @@ fn command_chip_width( cx: &mut Context, ) -> gpui::Pixels { match command.id { - RibbonCommandId::Undo | RibbonCommandId::Redo | RibbonCommandId::ToggleInvisibility => px(metrics.chip_height.as_f32()), + RibbonCommandId::Undo | RibbonCommandId::Redo | RibbonCommandId::Revisions | RibbonCommandId::ToggleInvisibility => { + px(metrics.chip_height.as_f32()) + }, RibbonCommandId::ExportFormat => { let text_width = measure_ribbon_text("Format", metrics.chip_text_size, window, cx).as_f32(); px(text_width + 10.0 + metrics.chip_padding_x.as_f32() * 2.0 + 10.0) @@ -247,6 +249,11 @@ fn modern_command_chip( let _ = workspace.update(cx, |workspace, cx| workspace.toggle_speech_document(panel_id, cx)); } }, + RibbonCommandId::Revisions => { + if let Some(workspace) = workspace.clone() { + let _ = workspace.update(cx, |workspace, cx| workspace.open_revision_dialog(window, cx)); + } + }, _ => { editor.update(cx, |editor, cx| { perform_ribbon_command(editor, command_id, window, cx); @@ -269,7 +276,7 @@ fn ribbon_command_color(command: &RibbonCommand, cx: &App) -> Hsla { | RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => cx.theme().info, RibbonCommandId::ToggleSpeechDocument => cx.theme().success, - RibbonCommandId::Undo | RibbonCommandId::Redo => cx.theme().primary, + RibbonCommandId::Undo | RibbonCommandId::Redo | RibbonCommandId::Revisions => cx.theme().primary, RibbonCommandId::ExportFormat | RibbonCommandId::ExportSend => cx.theme().info, RibbonCommandId::ToggleInvisibility => cx.theme().primary, } diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs index a229d6bd..0cf49e48 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs @@ -54,6 +54,7 @@ pub enum RibbonCommandId { Undo, Redo, + Revisions, ExportFormat, ExportSend, ToggleInvisibility, @@ -103,6 +104,7 @@ impl RibbonLabel { RibbonCommandId::ClearFormatting => Some("icons/eraser.svg"), RibbonCommandId::ExportFormat => Some("icons/export.svg"), RibbonCommandId::ExportSend => Some("icons/send-horizontal.svg"), + RibbonCommandId::Revisions => Some("icons/archive.svg"), _ => None, }; Self { diff --git a/crates/flowstate/src/rich_text_element/mod.rs b/crates/flowstate/src/rich_text_element/mod.rs index 16f42a9e..4bf4a4b9 100644 --- a/crates/flowstate/src/rich_text_element/mod.rs +++ b/crates/flowstate/src/rich_text_element/mod.rs @@ -64,6 +64,12 @@ pub fn flowstate_command_to_rich_text(command: CommandId) -> Option New and the empty-workspace New button. #[hotpath::measure] -pub fn new_blank_document() -> Document { +pub fn new_blank_document() -> DocumentProjection { blank_document() } diff --git a/crates/flowstate/src/workspace/icons.rs b/crates/flowstate/src/workspace/icons.rs index 49fa963d..65374815 100644 --- a/crates/flowstate/src/workspace/icons.rs +++ b/crates/flowstate/src/workspace/icons.rs @@ -1,8 +1,10 @@ #[cfg(target_os = "macos")] use gpui::ParentElement; +#[cfg(not(target_os = "macos"))] +use gpui_component::IconName; use gpui_component::button::Button; use gpui_component::button::ButtonVariants as _; -use gpui_component::{Icon, IconName, Sizable as _}; +use gpui_component::{Icon, Sizable as _}; #[derive(Clone, Copy)] pub enum AppIcon { diff --git a/crates/flowstate/src/workspace/mod.rs b/crates/flowstate/src/workspace/mod.rs index e4a2001f..8de0d31d 100644 --- a/crates/flowstate/src/workspace/mod.rs +++ b/crates/flowstate/src/workspace/mod.rs @@ -4,6 +4,7 @@ pub mod document_search_overlay; mod file_management; mod file_search_overlay; mod icons; +mod revision_dialog; mod workspace; pub use workspace::{Workspace, install_workspace_close_prompt, open_workspace_window}; diff --git a/crates/flowstate/src/workspace/revision_dialog.rs b/crates/flowstate/src/workspace/revision_dialog.rs new file mode 100644 index 00000000..f8f40e57 --- /dev/null +++ b/crates/flowstate/src/workspace/revision_dialog.rs @@ -0,0 +1,142 @@ +use flowstate_collab::crdt_runtime::RuntimeRevisionInfo; +use gpui::{App, Context, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, WeakEntity, Window, div, prelude::*, px}; +use gpui_component::{ + ActiveTheme as _, StyledExt as _, WindowExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + scroll::ScrollableElement, + v_flex, +}; +use uuid::Uuid; + +use super::Workspace; + +pub struct RevisionDialog { + workspace: WeakEntity, + panel_id: Uuid, + focus: FocusHandle, + loading: bool, + error: Option, + revisions: Vec, +} + +impl RevisionDialog { + pub fn new( + workspace: WeakEntity, + panel_id: Uuid, + receiver: Option>>>, + cx: &mut Context, + ) -> Self { + let loading = receiver.is_some(); + if let Some(receiver) = receiver { + cx.spawn(async move |dialog, cx| { + let result = receiver.recv().await; + let _ = dialog.update(cx, |dialog, cx| { + dialog.loading = false; + match result { + Ok(Ok(revisions)) => dialog.revisions = revisions, + Ok(Err(error)) => dialog.error = Some(error.to_string().into()), + Err(error) => dialog.error = Some(format!("Revision request closed: {error}").into()), + } + cx.notify(); + }); + }) + .detach(); + } + Self { + workspace, + panel_id, + focus: cx.focus_handle(), + loading, + error: (!loading).then(|| "This document has no revision runtime.".into()), + revisions: Vec::new(), + } + } + + pub fn focus(&self, window: &mut Window, _: &mut App) { + self.focus.focus(window); + } +} + +impl Focusable for RevisionDialog { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for RevisionDialog { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if self.loading { + div() + .py_6() + .text_color(cx.theme().muted_foreground) + .child("Loading revisions...") + .into_any_element() + } else if let Some(error) = self.error.clone() { + div() + .py_6() + .text_color(cx.theme().danger) + .child(error) + .into_any_element() + } else if self.revisions.is_empty() { + div() + .py_6() + .text_color(cx.theme().muted_foreground) + .child("No saved revisions yet.") + .into_any_element() + } else { + v_flex() + .gap_1() + .children(self.revisions.iter().map(|revision| { + let workspace = self.workspace.clone(); + let panel_id = self.panel_id; + let revision_id = revision.revision_id; + let title: SharedString = revision.title.clone().into(); + let summary: SharedString = revision.summary.clone().into(); + Button::new(("open-revision", revision_id as u64)) + .ghost() + .w_full() + .child( + h_flex() + .w_full() + .justify_between() + .gap_3() + .child( + v_flex() + .min_w_0() + .items_start() + .child(div().font_semibold().text_ellipsis().child(title)) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .text_ellipsis() + .child(summary), + ), + ) + .child( + div() + .flex_none() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{:08x}", revision_id as u64)), + ), + ) + .on_click(move |_, window, cx| { + let opened = workspace + .update(cx, |workspace, cx| workspace.open_document_revision(panel_id, revision_id, window, cx)) + .unwrap_or(false); + if opened { + window.close_dialog(cx); + } + }) + })) + .into_any_element() + }; + + v_flex() + .max_h(px(520.0)) + .min_h(px(160.0)) + .child(div().flex_1().overflow_y_scrollbar().child(content)) + } +} diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index 0a235b57..af64a4cd 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -1063,7 +1063,7 @@ fn toolkit_preview_document( fallback_text: &str, mut theme: DocumentTheme, expanded: bool, -) -> crate::rich_text_element::Document { +) -> crate::rich_text_element::DocumentProjection { theme.zoom_factor *= TOOLKIT_PREVIEW_ZOOM; theme.pageless_inset_x = px(10.0); theme.pageless_inset_top = px(8.0); diff --git a/crates/flowstate/src/workspace/workspace/collab.rs b/crates/flowstate/src/workspace/workspace/collab.rs new file mode 100644 index 00000000..01a773cd --- /dev/null +++ b/crates/flowstate/src/workspace/workspace/collab.rs @@ -0,0 +1,369 @@ +#[hotpath::measure_all] +impl Workspace { + pub fn open_collaboration_dialog(&mut self, window: &mut Window, cx: &mut Context) { + self.open_collaboration_dialog_with_mode(crate::collab::share_dialog::CollabDialogMode::Share, window, cx); + } + + pub fn open_join_collaboration_dialog(&mut self, window: &mut Window, cx: &mut Context) { + self.open_collaboration_dialog_with_mode(crate::collab::share_dialog::CollabDialogMode::Join, window, cx); + } + + pub fn close_collaboration_dialog(&mut self, cx: &mut Context) { + self.collaboration_dialog = None; + cx.notify(); + } + + fn open_collaboration_dialog_with_mode( + &mut self, + mode: crate::collab::share_dialog::CollabDialogMode, + window: &mut Window, + cx: &mut Context, + ) { + if self.collaboration_dialog.is_some() { + window.close_dialog(cx); + self.collaboration_dialog = None; + } + let workspace = cx.entity().downgrade(); + let panel_id = self.active_editor.as_ref().and(self.active_document_id); + let dialog = cx.new(|cx| crate::collab::share_dialog::CollabShareDialog::new(workspace, panel_id, mode, window, cx)); + let dialog_for_render = dialog.clone(); + let workspace_for_close = cx.entity().downgrade(); + window.open_dialog(cx, move |component_dialog, _, _| { + let workspace_for_close = workspace_for_close.clone(); + component_dialog + .title("Share / Collaborate") + .w(px(620.0)) + .max_w(px(620.0)) + .on_close(move |_, _, cx| { + let _ = workspace_for_close.update(cx, |workspace, cx| workspace.close_collaboration_dialog(cx)); + }) + .child(dialog_for_render.clone()) + }); + dialog.update(cx, |dialog, cx| dialog.focus(window, cx)); + self.collaboration_dialog = Some(dialog); + cx.notify(); + } + + pub fn start_collaboration_on_active_document(&mut self, cx: &mut Context) -> Option { + self.start_collaboration_on_document(self.active_document_id?, cx) + } + + pub fn start_collaboration_on_document(&mut self, panel_id: Uuid, cx: &mut Context) -> Option { + let panel = self + .document_panels + .iter() + .find(|panel| panel.read(cx).id() == panel_id)?; + let editor = panel.read(cx).editor(); + let title = self + .document_panels + .iter() + .find(|panel| panel.read(cx).id() == panel_id) + .map(|panel| panel.read(cx).title_text().to_string()) + .unwrap_or_else(|| "Shared document".to_string()); + let runtime = self.document_runtimes.get(&panel_id)?.clone(); + + tracing::info!(%panel_id, title = %title, "workspace starting collaboration on document"); + self.flush_document_runtime_edits(panel_id, editor.clone(), cx); + if editor.read(cx).runtime_edit_in_flight() { + tracing::warn!(%panel_id, "collaboration start deferred because local edits are still being committed to Loro"); + return None; + } + match crate::collab::start_session_for_panel(panel_id, editor, title, runtime, cx) { + Ok(session) => { + tracing::info!(%panel_id, %session, "workspace started collaboration on document"); + Some(session) + }, + Err(error) => { + tracing::error!(%panel_id, error = %format_args!("{error:#}"), "starting collaboration session failed"); + None + }, + } + } + + pub fn request_active_collaboration_ticket( + &mut self, + cx: &mut Context, + ) -> Option>> { + crate::collab::request_ticket_for_panel(self.active_document_id?, cx) + } + + pub fn copy_active_collaboration_ticket(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(panel_id) = self.active_document_id else { + return false; + }; + let mut ticket_rx = crate::collab::request_ticket_for_panel(panel_id, cx); + if ticket_rx.is_none() { + if self.start_collaboration_on_active_document(cx).is_none() { + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Share failed", + Some("The active document could not be shared."), + &[PromptButton::ok("Ok")], + cx, + )); + return true; + } + ticket_rx = crate::collab::request_ticket_for_panel(panel_id, cx); + } + + let Some(ticket_rx) = ticket_rx else { + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Share failed", + Some("The collaboration ticket is not available yet."), + &[PromptButton::ok("Ok")], + cx, + )); + return true; + }; + + let window_handle = window.window_handle(); + cx.spawn(async move |_, cx| { + let result = ticket_rx.recv().await; + let _ = window_handle.update(cx, |_, window, cx| match result { + Ok(Ok(ticket)) => { + tracing::info!(session = %ticket.session, inviter = %ticket.inviter.id, "copied collaboration invite ticket to clipboard"); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(ticket.encode_text())); + std::mem::drop(window.prompt( + PromptLevel::Info, + "Invite copied", + Some("The collaboration invite ticket was copied to the clipboard."), + &[PromptButton::ok("Ok")], + cx, + )); + }, + Ok(Err(error)) => { + tracing::error!(error = %format_args!("{error:#}"), "creating collaboration invite failed"); + let detail = format!("Creating collaboration invite failed: {error:#}"); + std::mem::drop(window.prompt(PromptLevel::Critical, "Share failed", Some(&detail), &[PromptButton::ok("Ok")], cx)); + }, + Err(error) => { + tracing::error!(error = %error, "collaboration invite receiver closed"); + let detail = format!("Creating collaboration invite failed: {error}"); + std::mem::drop(window.prompt(PromptLevel::Critical, "Share failed", Some(&detail), &[PromptButton::ok("Ok")], cx)); + }, + }); + }) + .detach(); + true + } + + pub fn join_collaboration_from_clipboard(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(item) = cx.read_from_clipboard() else { + tracing::warn!("join collaboration from clipboard failed because clipboard is empty"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some("The clipboard does not contain a collaboration invite ticket."), + &[PromptButton::ok("Ok")], + cx, + )); + return true; + }; + let Some(text) = item.text() else { + tracing::warn!("join collaboration from clipboard failed because clipboard has no text"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some("The clipboard does not contain text."), + &[PromptButton::ok("Ok")], + cx, + )); + return true; + }; + let ticket = match flowstate_collab::ticket::SessionTicket::decode_text(&text) { + Ok(ticket) => ticket, + Err(error) => { + tracing::warn!(bytes = text.len(), error = %format_args!("{error:#}"), "clipboard collaboration invite decode failed"); + let detail = format!("The clipboard text is not a valid collaboration invite: {error:#}"); + std::mem::drop(window.prompt(PromptLevel::Critical, "Join failed", Some(&detail), &[PromptButton::ok("Ok")], cx)); + return true; + }, + }; + tracing::info!(session = %ticket.session, inviter = %ticket.inviter.id, "joining collaboration session from clipboard"); + self.join_collaboration_session(ticket, window, cx).is_some() + } + + pub fn confirm_leave_collaboration_on_active_document(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self + .active_document_id + .is_some_and(|panel_id| self.confirm_leave_collaboration_on_panel(panel_id, window, cx)) + } + + pub fn confirm_leave_collaboration_on_panel(&mut self, panel_id: Uuid, window: &mut Window, cx: &mut Context) -> bool { + let phase = crate::collab::phase_for_panel(panel_id, cx); + if !collaboration_phase_blocks_close(phase.as_ref()) { + return false; + } + let detail = format!("{} Your copy of the document stays open.", collaboration_leave_detail(phase.as_ref())); + let answer = window.prompt( + PromptLevel::Warning, + "Leave this session?", + Some(&detail), + &[PromptButton::ok("Leave"), PromptButton::cancel("Cancel")], + cx, + ); + cx.spawn(async move |workspace, cx| { + if !matches!(answer.await, Ok(0)) { + return; + } + let _ = workspace.update(cx, |workspace, cx| { + workspace.leave_collaboration_on_panel(panel_id, cx); + }); + }) + .detach(); + true + } + + pub fn leave_collaboration_on_panel(&mut self, panel_id: Uuid, cx: &mut Context) -> bool { + let left = crate::collab::leave_session_for_panel(panel_id, cx); + if left + && let Some(runtime) = self.document_runtimes.get(&panel_id).cloned() + { + self.attach_runtime_to_document_panel(panel_id, runtime, cx); + } + tracing::info!(%panel_id, left, "workspace leave collaboration requested"); + left + } + + pub fn join_collaboration_session( + &mut self, + ticket: flowstate_collab::ticket::SessionTicket, + window: &mut Window, + cx: &mut Context, + ) -> Option { + tracing::info!(session = %ticket.session, inviter = %ticket.inviter.id, "workspace joining collaboration session"); + let request = match crate::collab::join_session(ticket, cx) { + Ok(request) => request, + Err(error) => { + tracing::error!(error = %format_args!("{error:#}"), "joining collaboration session failed"); + let detail = format!("Joining collaboration session failed: {error:#}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + return None; + }, + }; + let session = request.session; + let completed = request.completed; + let window_handle = window.window_handle(); + + cx.spawn(async move |workspace, cx| { + let result = completed.recv().await; + let _ = window_handle.update(cx, |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| { + match result { + Ok(Ok(joined)) => { + tracing::info!(session = %joined.session, title = %joined.title, "collaboration join completed; opening joined document"); + let Some(runtime) = crate::collab::runtime_for_session(joined.session, cx) else { + tracing::error!(session = %joined.session, "joined collaboration runtime is unavailable"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some("The joined collaboration runtime is unavailable."), + &[PromptButton::ok("Ok")], + cx, + )); + return; + }; + let panel = match workspace.add_joined_collaboration_panel(joined.document, joined.title, runtime.clone(), window, cx) { + Ok(panel) => panel, + Err(error) => { + tracing::error!(session = %joined.session, error = %format_args!("{error:#}"), "starting joined document runtime failed"); + let detail = format!("Joined document runtime could not be started: {error:#}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + return; + }, + }; + let panel_id = panel.read(cx).id(); + let editor = panel.read(cx).editor(); + if let Err(error) = crate::collab::attach_joined_session(joined.session, panel_id, editor, cx) { + tracing::error!(session = %joined.session, %panel_id, error = %format_args!("{error:#}"), "collaboration joined document attachment failed"); + let detail = format!("Joined document opened, but collaboration attachment failed: {error:#}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + } else { + workspace.attach_runtime_to_document_panel(panel_id, runtime, cx); + if workspace.collaboration_dialog.is_some() { + workspace.close_collaboration_dialog(cx); + window.close_dialog(cx); + } + } + }, + Ok(Err(error)) => { + tracing::error!(%session, error = %format_args!("{error:#}"), "joining collaboration session failed"); + let detail = format!("Joining collaboration session failed: {error:#}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + }, + Err(error) => { + tracing::error!(%session, error = %error, "collaboration join completion channel closed"); + let detail = format!("Joining collaboration session failed: {error}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + }, + } + }); + }); + }) + .detach(); + + Some(session) + } + + fn add_joined_collaboration_panel( + &mut self, + document: DocumentProjection, + title: String, + runtime: flowstate_collab::crdt_runtime_actor::CrdtRuntimeHandle, + window: &mut Window, + cx: &mut Context, + ) -> anyhow::Result> { + let panel = self.create_document_panel(document, None, Some(title), DocumentRuntimeSource::Handle(runtime), window, cx)?; + self.persist_temporary_workspace_session(cx); + cx.notify(); + Ok(panel) + } + + pub fn leave_collaboration_on_active_document(&mut self, cx: &mut Context) -> bool { + self + .active_document_id + .is_some_and(|panel_id| self.leave_collaboration_on_panel(panel_id, cx)) + } + + pub fn leave_all_collaboration_sessions(&mut self, cx: &mut Context) { + let panel_ids = self + .document_panels + .iter() + .map(|panel| panel.read(cx).id()) + .collect::>(); + for panel_id in panel_ids { + crate::collab::leave_session_for_panel(panel_id, cx); + } + } +} diff --git a/crates/flowstate/src/workspace/workspace/collab_prompts.rs b/crates/flowstate/src/workspace/workspace/collab_prompts.rs new file mode 100644 index 00000000..b1dd6b2f --- /dev/null +++ b/crates/flowstate/src/workspace/workspace/collab_prompts.rs @@ -0,0 +1,323 @@ +#[derive(Clone)] +struct CollaborationClosePanel { + id: Uuid, + panel: PanelKind, + save_prompt: CollaborationSavePrompt, +} + +#[derive(Clone, Copy)] +enum CollaborationSavePrompt { + Clean, + Dirty, + Pathless, +} + +enum CollaborationCloseResolution { + Proceed { discard: Option }, + Cancelled, +} + +#[hotpath::measure_all] +impl Workspace { + fn close_collaboration_document_panel( + &mut self, + panel_id: Uuid, + panel_kind: PanelKind, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let phase = crate::collab::phase_for_panel(panel_id, cx); + if !collaboration_phase_blocks_close(phase.as_ref()) { + return false; + } + + let detail = collaboration_leave_detail(phase.as_ref()); + let save_prompt = collaboration_save_prompt_for_panel(&panel_kind, cx); + let answer = window.prompt( + PromptLevel::Warning, + "Leave the collaboration session?", + Some(&detail), + &[PromptButton::ok("Leave"), PromptButton::cancel("Cancel")], + cx, + ); + let window_handle = window.window_handle(); + cx.spawn(async move |workspace, cx| { + if !matches!(answer.await, Ok(0)) { + return; + } + + let resolution = resolve_collaboration_save(panel_kind, save_prompt, window_handle, cx).await; + let CollaborationCloseResolution::Proceed { discard } = resolution else { + return; + }; + if let Some(panel) = discard { + panel.discard(cx); + } + + let _ = window_handle.update(cx, |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| { + workspace.leave_collaboration_on_panel(panel_id, cx); + workspace.remove_document_panel(panel_id, window, cx); + }); + }); + }) + .detach(); + true + } + + fn request_close_window_with_collaboration(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let collaboration_panels = self.collaboration_close_panels(cx); + if collaboration_panels.is_empty() { + return false; + } + + let collaboration_ids = collaboration_panels + .iter() + .map(|panel| panel.id) + .collect::>(); + let dirty_panels = self + .dirty_panels(cx) + .into_iter() + .filter(|panel| !collaboration_ids.contains(&panel.panel_id(cx))) + .collect::>(); + + let answer = if collaboration_panels.len() == 1 { + let phase = crate::collab::phase_for_panel(collaboration_panels[0].id, cx); + window.prompt( + PromptLevel::Warning, + "Leave the collaboration session and quit?", + Some(&collaboration_leave_detail(phase.as_ref())), + &[PromptButton::ok("Leave"), PromptButton::cancel("Cancel")], + cx, + ) + } else { + let detail = format!( + "You're in {} collaboration sessions. Leave all and quit?", + collaboration_panels.len() + ); + window.prompt( + PromptLevel::Warning, + "Leave collaboration sessions?", + Some(&detail), + &[PromptButton::ok("Leave"), PromptButton::cancel("Cancel")], + cx, + ) + }; + let window_handle = window.window_handle(); + + cx.spawn(async move |workspace, cx| { + if !matches!(answer.await, Ok(0)) { + return; + } + + let mut discards = Vec::new(); + for panel in collaboration_panels { + match resolve_collaboration_save(panel.panel, panel.save_prompt, window_handle, cx).await { + CollaborationCloseResolution::Proceed { discard } => { + if let Some(discard) = discard { + discards.push(discard); + } + }, + CollaborationCloseResolution::Cancelled => return, + } + } + + if !resolve_dirty_window_close(dirty_panels, window_handle, cx).await { + return; + } + + for panel in discards { + panel.discard(cx); + } + + let _ = window_handle.update(cx, |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| workspace.leave_all_collaboration_sessions(cx)); + window.remove_window(); + }); + }) + .detach(); + true + } + + fn collaboration_close_panels(&self, cx: &App) -> Vec { + self + .document_panels + .iter() + .filter_map(|panel| { + let panel_state = panel.read(cx); + let id = panel_state.id(); + let phase = crate::collab::phase_for_panel(id, cx); + collaboration_phase_blocks_close(phase.as_ref()).then(|| { + let panel_kind = PanelKind::Document { + panel: panel.clone(), + editor: panel_state.editor(), + }; + CollaborationClosePanel { + id, + save_prompt: collaboration_save_prompt_for_panel(&panel_kind, cx), + panel: panel_kind, + } + }) + }) + .collect() + } +} + +#[hotpath::measure_all] +impl PanelKind { + fn panel_id(&self, cx: &App) -> Uuid { + match self { + PanelKind::Document { panel, .. } => panel.read(cx).id(), + PanelKind::Flow { panel, .. } => panel.read(cx).id(), + } + } +} + +#[hotpath::measure] +async fn resolve_collaboration_save( + panel: PanelKind, + prompt: CollaborationSavePrompt, + window_handle: AnyWindowHandle, + cx: &mut gpui::AsyncApp, +) -> CollaborationCloseResolution { + match prompt { + CollaborationSavePrompt::Clean => CollaborationCloseResolution::Proceed { discard: None }, + CollaborationSavePrompt::Dirty | CollaborationSavePrompt::Pathless => { + let answer = match window_handle.update(cx, |_, window, cx| { + let (detail, save_label) = match prompt { + CollaborationSavePrompt::Pathless => ("This shared tab has no saved file.", "Save As..."), + CollaborationSavePrompt::Dirty => ("This document has unsaved changes.", "Save"), + CollaborationSavePrompt::Clean => unreachable!(), + }; + window.prompt( + PromptLevel::Warning, + "Save changes before closing?", + Some(detail), + &[PromptButton::ok(save_label), PromptButton::new("Don't Save"), PromptButton::cancel("Cancel")], + cx, + ) + }) { + Ok(answer) => answer, + Err(error) => { + show_save_failed(window_handle, cx, format!("failed to show save prompt: {error}")); + return CollaborationCloseResolution::Cancelled; + }, + }; + + match answer.await { + Ok(0) => match panel.save(window_handle, cx).await { + PanelSaveOutcome::Saved => CollaborationCloseResolution::Proceed { discard: None }, + PanelSaveOutcome::Cancelled => CollaborationCloseResolution::Cancelled, + PanelSaveOutcome::Failed(error) => { + show_save_failed(window_handle, cx, error); + CollaborationCloseResolution::Cancelled + }, + }, + Ok(1) => CollaborationCloseResolution::Proceed { discard: Some(panel) }, + _ => CollaborationCloseResolution::Cancelled, + } + }, + } +} + +#[hotpath::measure] +async fn resolve_dirty_window_close( + dirty_panels: Vec, + window_handle: AnyWindowHandle, + cx: &mut gpui::AsyncApp, +) -> bool { + if dirty_panels.is_empty() { + return true; + } + let message = if dirty_panels.len() == 1 { + "This document has unsaved changes." + } else { + "One or more documents have unsaved changes." + }; + let answer = match window_handle.update(cx, |_, window, cx| { + window.prompt( + PromptLevel::Warning, + "Save changes before closing?", + Some(message), + &[PromptButton::ok("Save"), PromptButton::new("Don't Save"), PromptButton::cancel("Cancel")], + cx, + ) + }) { + Ok(answer) => answer, + Err(error) => { + show_save_failed(window_handle, cx, format!("failed to show save prompt: {error}")); + return false; + }, + }; + + match answer.await { + Ok(0) => { + for panel in dirty_panels { + match panel.save(window_handle, cx).await { + PanelSaveOutcome::Saved => {}, + PanelSaveOutcome::Cancelled => return false, + PanelSaveOutcome::Failed(error) => { + show_save_failed(window_handle, cx, error); + return false; + }, + } + } + true + }, + Ok(1) => { + for panel in dirty_panels { + panel.discard(cx); + } + true + }, + _ => false, + } +} + +#[hotpath::measure] +fn collaboration_save_prompt_for_panel(panel: &PanelKind, cx: &App) -> CollaborationSavePrompt { + match panel { + PanelKind::Document { editor, .. } => { + let editor = editor.read(cx); + if editor.document_path().is_none() { + CollaborationSavePrompt::Pathless + } else if editor.has_unsaved_changes() { + CollaborationSavePrompt::Dirty + } else { + CollaborationSavePrompt::Clean + } + }, + PanelKind::Flow { editor, .. } => { + let editor = editor.read(cx); + if editor.document_path().is_none() { + CollaborationSavePrompt::Pathless + } else if editor.has_unsaved_changes() { + CollaborationSavePrompt::Dirty + } else { + CollaborationSavePrompt::Clean + } + }, + } +} + +#[hotpath::measure] +fn collaboration_phase_blocks_close(phase: Option<&crate::collab::SessionPhase>) -> bool { + phase.is_some_and(|phase| !matches!(phase, crate::collab::SessionPhase::Detached(_))) +} + +#[hotpath::measure] +fn collaboration_leave_detail(phase: Option<&crate::collab::SessionPhase>) -> String { + match phase { + Some(crate::collab::SessionPhase::Attached(attachment)) if attachment.peers_present == 0 => { + "This tab is live, but no other people are present right now.".to_string() + }, + Some(crate::collab::SessionPhase::Attached(attachment)) => format!( + "This tab is live with {} other {}.", + attachment.peers_present, + if attachment.peers_present == 1 { "person" } else { "people" } + ), + Some(crate::collab::SessionPhase::Creating) => "This tab is starting a collaboration session.".to_string(), + Some(crate::collab::SessionPhase::Joining(_)) => "This tab is joining a collaboration session.".to_string(), + _ => "This tab is connected to a collaboration session.".to_string(), + } +} diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 7255ce76..699ae88c 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -1,6 +1,13 @@ +enum DocumentRuntimeSource { + FromProjection, + Runtime(Box), + Handle(flowstate_collab::crdt_runtime_actor::CrdtRuntimeHandle), +} + #[hotpath::measure_all] impl Workspace { pub fn new(initial_path: Option, window: &mut Window, cx: &mut Context) -> Self { + crate::collab::init(cx); let zoom_slider = cx.new(|_| { SliderState::new() .min(25.0) @@ -49,6 +56,8 @@ impl Workspace { let mut this = Self { document_panels: Vec::new(), + document_runtimes: HashMap::new(), + document_runtime_flush_pending: HashSet::new(), flow_panels: Vec::new(), active_document_id: None, active_editor: None, @@ -88,6 +97,10 @@ impl Workspace { autosave_enabled: load_autosave(), autosave_document_generations: HashMap::new(), autosave_flow_in_flight: HashSet::new(), + collaboration_dialog: None, + revision_dialog: None, + collab_notice_subscriptions: HashMap::new(), + collab_incompatible_version_notices: HashSet::new(), file_search_overlay: None, tub_root: None, tub_index: None, @@ -143,19 +156,165 @@ impl Workspace { fn create_document_panel( &mut self, - mut document: Document, + mut document: DocumentProjection, path: Option, title: Option, + runtime: DocumentRuntimeSource, window: &mut Window, cx: &mut Context, - ) -> Entity { + ) -> anyhow::Result> { // DB8 stores style assignments, not style appearance. The render theme is // local user preference loaded from app settings. document.theme = load_document_theme(); + let runtime_title = title + .as_deref() + .or_else(|| path.as_deref().and_then(Path::file_name).and_then(|name| name.to_str())) + .unwrap_or("Flowstate Document"); + // §15/§31: freshly spawned runtimes get the durable author identity bound + // below. A `Handle` is an already-live collaboration runtime that registered + // its identity at join time (see `CollabSession::finish_join_snapshot`), so + // re-binding it here would be a redundant second call on the same runtime. + let mut register_author_identity = true; + let runtime = match runtime { + DocumentRuntimeSource::FromProjection => { + let imported = flowstate_document::import_document_projection(document, runtime_title) + .map_err(|error| anyhow::anyhow!("creating canonical Loro document failed: {error}"))?; + let runtime = flowstate_collab::crdt_runtime::CrdtRuntime::from_imported_document(imported) + .map_err(|error| anyhow::anyhow!("creating canonical Loro runtime failed: {error:#}"))?; + document = runtime + .projection_snapshot() + .map_err(|error| anyhow::anyhow!("reading canonical startup projection failed: {error:#}"))?; + flowstate_collab::crdt_runtime_actor::CrdtRuntimeHandle::spawn(runtime) + .map_err(|error| anyhow::anyhow!("starting canonical Loro runtime failed: {error:#}"))? + }, + DocumentRuntimeSource::Runtime(runtime) => { + let local_theme = document.theme.clone(); + document = runtime + .projection_snapshot() + .map_err(|error| anyhow::anyhow!("reading canonical startup projection failed: {error:#}"))?; + document.theme = local_theme; + flowstate_collab::crdt_runtime_actor::CrdtRuntimeHandle::spawn(*runtime) + .map_err(|error| anyhow::anyhow!("starting canonical Loro runtime failed: {error:#}"))? + }, + DocumentRuntimeSource::Handle(runtime) => { + register_author_identity = false; + runtime + }, + }; + + if register_author_identity { + // Fire-and-forget: a failure to register author identity must never block + // or break opening the document. The settings read/persist happens on a + // background thread to keep the foreground non-blocking. + let identity_runtime = runtime.clone(); + cx.spawn(async move |_, cx| { + let (user_id, display_name) = cx + .background_executor() + .spawn(async { load_local_user_identity() }) + .await; + if let Err(error) = identity_runtime.set_author_identity(user_id, display_name).await { + tracing::warn!(error = %format_args!("{error:#}"), "binding durable author identity to document runtime failed"); + } + }) + .detach(); + } + let editor = cx.new(|cx| RichTextEditor::new_with_path(document, path.clone(), cx)); let smart_word_selection = load_smart_word_selection(); + let save_runtime = runtime.clone(); + let export_runtime = runtime.clone(); + let undo_runtime = runtime.clone(); + let recovery_runtime = runtime.clone(); editor.update(cx, |editor, cx| { editor.set_smart_word_selection(smart_word_selection, cx); + editor.set_runtime_capture(true); + editor.set_native_save_hook(Some(Rc::new(move |path, pending_edits, assets| { + let runtime = save_runtime.clone(); + Box::pin(async move { + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(pending_edits); + runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after) + .await + .map_err(runtime_io_error)?; + let title = document_package_title_for_path(&path); + // The checkpoint's LocalUpdate events are intentionally dropped here: + // this UI-agnostic save hook future has no GPUI context to reach the + // collaboration session. The workspace re-syncs collaborators after + // the save completes via collab::refresh_after_external_checkpoint. + runtime + .checkpoint_package(title, Some(path)) + .await + .map_err(runtime_io_error)?; + runtime.projection_snapshot().await.map_err(runtime_io_error) + }) + }))); + editor.set_native_export_hook(Some(Rc::new(move |path, format, pending_edits, assets| { + let runtime = export_runtime.clone(); + Box::pin(async move { + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(pending_edits); + runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after) + .await + .map_err(runtime_io_error)?; + match format { + crate::rich_text_element::DocumentExportFormat::Native + | crate::rich_text_element::DocumentExportFormat::NativeWithExtension(_) => { + let bytes = runtime + .package_bytes(document_package_title_for_path(&path)) + .await + .map_err(runtime_io_error)?; + write_bytes_to_path(&path, &bytes)?; + }, + crate::rich_text_element::DocumentExportFormat::Docx => { + let document = runtime.projection_snapshot().await.map_err(runtime_io_error)?; + crate::docx_conversion::write_docx(&path, &document)?; + }, + crate::rich_text_element::DocumentExportFormat::Pdf => { + let document = runtime.projection_snapshot().await.map_err(runtime_io_error)?; + let bytes = runtime.package_bytes("PDF Source".to_string()).await.map_err(runtime_io_error)?; + crate::docx_conversion::write_pdf_with_db8_bytes(&path, &document, &bytes)?; + }, + }; + runtime.projection_snapshot().await.map_err(runtime_io_error) + }) + }))); + editor.set_native_undo_hook(Some(Rc::new(move |redirect, pending_edits, assets| { + let runtime = undo_runtime.clone(); + Box::pin(async move { + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(pending_edits); + runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after) + .await + .map_err(runtime_io_error)?; + let command = match redirect { + crate::rich_text_element::UndoRedirect::Undo => flowstate_collab::crdt_runtime::SemanticCommand::Undo, + crate::rich_text_element::UndoRedirect::Redo => flowstate_collab::crdt_runtime::SemanticCommand::Redo, + }; + let events = runtime.command(command).await.map_err(runtime_io_error)?; + if events.is_empty() { + return Ok(None); + } + let selection = events.into_iter().find_map(|event| match event { + flowstate_collab::crdt_runtime::RuntimeEvent::SelectionRestored { selection } => Some(selection), + _ => None, + }); + let document = runtime.projection_snapshot().await.map_err(runtime_io_error)?; + Ok(Some(crate::rich_text_element::NativeUndoResult { + document, + selection, + })) + }) + }))); + editor.set_native_recovery_hook(Some(Rc::new(move |path| { + let runtime = recovery_runtime.clone(); + Box::pin(async move { + let bytes = runtime + .package_bytes("Recovery snapshot".to_string()) + .await + .map_err(runtime_io_error)?; + write_bytes_to_path(&path, &bytes) + }) + }))); }); let workspace = cx.entity().downgrade(); let title = title @@ -173,9 +332,11 @@ impl Workspace { } let panel = cx.new(|cx| DocumentPanel::new_with_title(title, path, editor.clone(), workspace, window, cx)); let id = panel.read(cx).id(); + self.document_runtimes.insert(id, runtime); self.editor_subscriptions.push(( id, cx.observe(&editor, move |workspace, editor, cx| { + workspace.schedule_document_runtime_flush(id, editor.clone(), cx); let viewport_paragraph = workspace.active_editor_viewport_paragraph(cx); workspace.update_outline_viewport_paragraph(viewport_paragraph, cx); workspace.maybe_autosave_document(id, editor.clone(), cx); @@ -185,7 +346,316 @@ impl Workspace { self.active_editor = Some(editor); self.active_flow = None; self.document_panels.push(panel.clone()); - panel + Ok(panel) + } + + pub(crate) fn attach_runtime_to_document_panel( + &mut self, + panel_id: Uuid, + runtime: flowstate_collab::crdt_runtime_actor::CrdtRuntimeHandle, + cx: &mut Context, + ) { + let Some(panel) = self + .document_panels + .iter() + .find(|panel| panel.read(cx).id() == panel_id) + else { + return; + }; + let editor = panel.read(cx).editor(); + let runtime_capture = crate::collab::session_for_panel(panel_id, cx).is_none(); + let save_runtime = runtime.clone(); + let export_runtime = runtime.clone(); + let undo_runtime = runtime.clone(); + let recovery_runtime = runtime.clone(); + editor.update(cx, |editor, _| { + editor.set_runtime_capture(runtime_capture); + editor.set_native_save_hook(Some(Rc::new(move |path, pending_edits, assets| { + let runtime = save_runtime.clone(); + Box::pin(async move { + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(pending_edits); + runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after) + .await + .map_err(runtime_io_error)?; + // LocalUpdate events from this checkpoint are intentionally dropped + // (this save hook future has no GPUI context); the workspace re-syncs + // collaborators afterwards via collab::refresh_after_external_checkpoint. + runtime + .checkpoint_package(document_package_title_for_path(&path), Some(path)) + .await + .map_err(runtime_io_error)?; + runtime.projection_snapshot().await.map_err(runtime_io_error) + }) + }))); + editor.set_native_export_hook(Some(Rc::new(move |path, format, pending_edits, assets| { + let runtime = export_runtime.clone(); + Box::pin(async move { + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(pending_edits); + runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after) + .await + .map_err(runtime_io_error)?; + match format { + crate::rich_text_element::DocumentExportFormat::Native + | crate::rich_text_element::DocumentExportFormat::NativeWithExtension(_) => { + let bytes = runtime + .package_bytes(document_package_title_for_path(&path)) + .await + .map_err(runtime_io_error)?; + write_bytes_to_path(&path, &bytes)?; + }, + crate::rich_text_element::DocumentExportFormat::Docx => { + let document = runtime.projection_snapshot().await.map_err(runtime_io_error)?; + crate::docx_conversion::write_docx(&path, &document)?; + }, + crate::rich_text_element::DocumentExportFormat::Pdf => { + let document = runtime.projection_snapshot().await.map_err(runtime_io_error)?; + let bytes = runtime.package_bytes("PDF Source".to_string()).await.map_err(runtime_io_error)?; + crate::docx_conversion::write_pdf_with_db8_bytes(&path, &document, &bytes)?; + }, + }; + runtime.projection_snapshot().await.map_err(runtime_io_error) + }) + }))); + if runtime_capture { + editor.set_native_undo_hook(Some(Rc::new(move |redirect, pending_edits, assets| { + let runtime = undo_runtime.clone(); + Box::pin(async move { + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(pending_edits); + runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after) + .await + .map_err(runtime_io_error)?; + let command = match redirect { + crate::rich_text_element::UndoRedirect::Undo => flowstate_collab::crdt_runtime::SemanticCommand::Undo, + crate::rich_text_element::UndoRedirect::Redo => flowstate_collab::crdt_runtime::SemanticCommand::Redo, + }; + let events = runtime.command(command).await.map_err(runtime_io_error)?; + if events.is_empty() { + return Ok(None); + } + let selection = events.into_iter().find_map(|event| match event { + flowstate_collab::crdt_runtime::RuntimeEvent::SelectionRestored { selection } => Some(selection), + _ => None, + }); + let document = runtime.projection_snapshot().await.map_err(runtime_io_error)?; + Ok(Some(crate::rich_text_element::NativeUndoResult { + document, + selection, + })) + }) + }))); + } else { + editor.set_native_undo_hook(None); + } + editor.set_native_recovery_hook(Some(Rc::new(move |path| { + let runtime = recovery_runtime.clone(); + Box::pin(async move { + let bytes = runtime + .package_bytes("Recovery snapshot".to_string()) + .await + .map_err(runtime_io_error)?; + write_bytes_to_path(&path, &bytes) + }) + }))); + }); + self.document_runtimes.insert(panel_id, runtime); + } + + pub fn request_document_revisions( + &self, + panel_id: Uuid, + cx: &mut Context, + ) -> Option>>> { + let runtime = self.document_runtimes.get(&panel_id)?.clone(); + let (tx, rx) = async_channel::bounded(1); + cx.spawn(async move |_, _| { + let _ = tx.send(runtime.revisions().await).await; + }) + .detach(); + Some(rx) + } + + pub fn open_revision_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(panel_id) = self.active_document_id else { + return; + }; + if self.revision_dialog.is_some() { + window.close_dialog(cx); + self.revision_dialog = None; + } + let workspace = cx.entity().downgrade(); + let revisions = self.request_document_revisions(panel_id, cx); + let dialog = cx.new(|cx| { + crate::workspace::revision_dialog::RevisionDialog::new(workspace, panel_id, revisions, cx) + }); + let dialog_for_render = dialog.clone(); + let workspace_for_close = cx.entity().downgrade(); + window.open_dialog(cx, move |component_dialog, _, _| { + let workspace_for_close = workspace_for_close.clone(); + component_dialog + .title("Document Revisions") + .w(px(520.0)) + .max_w(px(520.0)) + .on_close(move |_, _, cx| { + let _ = workspace_for_close.update(cx, |workspace, cx| { + workspace.revision_dialog = None; + cx.notify(); + }); + }) + .child(dialog_for_render.clone()) + }); + dialog.update(cx, |dialog, cx| dialog.focus(window, cx)); + self.revision_dialog = Some(dialog); + cx.notify(); + } + + pub fn open_document_revision( + &mut self, + panel_id: Uuid, + revision_id: u128, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(runtime) = self.document_runtimes.get(&panel_id).cloned() else { + return false; + }; + let window_handle = window.window_handle(); + cx.spawn(async move |workspace, cx| { + let result = runtime + .command(flowstate_collab::crdt_runtime::SemanticCommand::ForkRevision { revision_id }) + .await; + let _ = window_handle.update(cx, |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| match result { + Ok(events) => { + let fork = events.into_iter().find_map(|event| match event { + flowstate_collab::crdt_runtime::RuntimeEvent::RevisionForked { + document, + package, + .. + } => Some((*document, *package)), + _ => None, + }); + let Some((document, package)) = fork else { + tracing::warn!(revision_id, "revision fork command returned no fork payload"); + return; + }; + let fork_runtime = match flowstate_collab::crdt_runtime::CrdtRuntime::from_package(package, None) { + Ok(runtime) => runtime, + Err(error) => { + tracing::error!(revision_id, error = %format_args!("{error:#}"), "creating revision fork runtime failed"); + return; + }, + }; + let panel = match workspace.create_document_panel( + document, + None, + Some(format!("Revision {revision_id:x}")), + DocumentRuntimeSource::Runtime(Box::new(fork_runtime)), + window, + cx, + ) { + Ok(panel) => panel, + Err(error) => { + tracing::error!(revision_id, error = %format_args!("{error:#}"), "starting revision fork runtime failed"); + return; + }, + }; + panel + .read(cx) + .editor() + .update(cx, |editor, cx| editor.mark_as_unsaved_branch(cx)); + }, + Err(error) => { + tracing::error!(revision_id, error = %format_args!("{error:#}"), "opening document revision failed"); + }, + }); + }); + }) + .detach(); + true + } + + fn flush_document_runtime_edits(&mut self, panel_id: Uuid, editor: Entity, cx: &mut Context) { + // Keep exactly one optimistic command batch in flight. Later keystrokes + // remain rendered locally and are rebased onto the acknowledged frontier + // when this batch completes. + if editor.read(cx).runtime_edit_in_flight() { + return; + } + let edits = editor.update(cx, |editor, _| editor.take_pending_runtime_edits()); + if edits.is_empty() { + return; + } + let Some(runtime) = self.document_runtimes.get(&panel_id).cloned() else { + return; + }; + editor.update(cx, |editor, _| editor.begin_runtime_edit()); + let assets = editor + .read(cx) + .document() + .assets + .assets + .values() + .cloned() + .collect(); + let (base_frontier, commands, selection_after) = flatten_runtime_edit_commands(edits); + let acknowledge_without_projection_replay = !commands.is_empty() + && commands + .iter() + .all(crate::rich_text_element::SemanticEditCommand::can_acknowledge_without_projection_replay); + let retry_edit = (!commands.is_empty()).then(|| crate::rich_text_element::SemanticCommandBatch { + base_frontier: base_frontier.clone(), + semantic_commands: commands.clone(), + selection_after: selection_after.clone(), + }); + cx.spawn(async move |workspace, cx| { + let result = runtime + .apply_editor_commands(base_frontier, commands, assets, selection_after.clone()) + .await; + let stale_snapshot = match &result { + Err(error) if error.downcast_ref::().is_some() => { + runtime.projection_snapshot().await.ok() + }, + _ => None, + }; + let _ = workspace.update(cx, |_, cx| match result { + Ok(events) => apply_local_runtime_events( + &editor, + events, + acknowledge_without_projection_replay, + selection_after, + cx, + ), + Err(error) => { + if let Some(document) = stale_snapshot { + tracing::debug!(%panel_id, "discarding stale optimistic projection and restoring the canonical Loro projection"); + editor.update(cx, |editor, cx| { + editor.replace_document_projection_replaying_pending(document, retry_edit.into_iter().collect(), None, cx); + }); + } else { + tracing::error!(%panel_id, error = %error, "failed to apply editor edits to Loro runtime"); + editor.update(cx, |editor, cx| editor.complete_runtime_edit(None, cx)); + } + }, + }); + }) + .detach(); + } + + fn schedule_document_runtime_flush(&mut self, panel_id: Uuid, editor: Entity, cx: &mut Context) { + if !self.document_runtimes.contains_key(&panel_id) || !self.document_runtime_flush_pending.insert(panel_id) { + return; + } + cx.spawn(async move |workspace, cx| { + Timer::after(Duration::from_millis(DOCUMENT_RUNTIME_FLUSH_DEBOUNCE_MS)).await; + let _ = workspace.update(cx, |workspace, cx| { + workspace.document_runtime_flush_pending.remove(&panel_id); + workspace.flush_document_runtime_edits(panel_id, editor, cx); + }); + }) + .detach(); } pub fn set_active_document(&mut self, panel_id: Uuid, editor: Entity, cx: &mut Context) { @@ -214,6 +684,9 @@ impl Workspace { } pub fn remove_document_panel(&mut self, panel_id: Uuid, _: &mut Window, cx: &mut Context) { + crate::collab::leave_session_for_panel(panel_id, cx); + self.document_runtimes.remove(&panel_id); + self.document_runtime_flush_pending.remove(&panel_id); let closing_active_document = self.active_document_id == Some(panel_id); if let Some(panel) = self .document_panels @@ -314,11 +787,19 @@ impl Workspace { .spawn(async move { load_workspace_document(path) }) .await; match loaded { - Ok(LoadedWorkspaceDocument::Document { document, path, title }) => { + Ok(LoadedWorkspaceDocument::Document { + document, + runtime, + path, + title, + }) => { let _ = window_handle.update(cx, |_, window, cx| { let _ = workspace.update(cx, |workspace, cx| { + workspace + .recent_document_previews + .insert(path_for_recent.clone(), recent_document_preview_document(&document)); workspace.record_recent_document(path_for_recent.clone(), cx); - workspace.add_document_panel_with_title(*document, path, title, window, cx); + workspace.add_document_panel_with_title(*document, path, title, *runtime, window, cx); if let Some(paragraph_ix) = target_paragraph_ix { workspace.scroll_active_editor_to_paragraph(paragraph_ix, window, cx); cx.on_next_frame(window, move |workspace, window, cx| { @@ -464,10 +945,17 @@ impl Workspace { let id = match loaded { LoadedWorkspaceDocument::Document { document, + runtime, path, title, } => { - let panel = self.create_document_panel(*document, path, title, window, cx); + let panel = match self.create_document_panel(*document, path, title, DocumentRuntimeSource::Runtime(runtime), window, cx) { + Ok(panel) => panel, + Err(error) => { + tracing::error!(error = %format_args!("{error:#}"), "restoring document runtime failed"); + continue; + }, + }; let panel_id = panel.read(cx).id(); panel.update(cx, |panel, _| { if !entry.collapsed_outline_items.is_empty() { @@ -548,9 +1036,9 @@ impl Workspace { .spawn({ let path = path.clone(); async move { - let mut loaded = load_document_for_open(&path).ok()?; - loaded.document.theme = load_document_theme(); - Some(recent_document_preview_document(&loaded.document)) + let mut document = load_document_preview(&path).ok()?; + document.theme = load_document_theme(); + Some(recent_document_preview_document(&document)) } }) .await; @@ -609,23 +1097,52 @@ impl Workspace { .detach(); } - fn add_document_panel(&mut self, document: Document, path: Option, window: &mut Window, cx: &mut Context) { - self.create_document_panel(document, path, None, window, cx); - self.persist_temporary_workspace_session(cx); - cx.notify(); + fn add_document_panel(&mut self, document: DocumentProjection, path: Option, window: &mut Window, cx: &mut Context) { + match self.create_document_panel(document, path, None, DocumentRuntimeSource::FromProjection, window, cx) { + Ok(_) => { + self.persist_temporary_workspace_session(cx); + cx.notify(); + }, + Err(error) => { + let detail = format!("The canonical Loro runtime could not be started: {error:#}"); + tracing::error!(error = %format_args!("{error:#}"), "creating document panel failed"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Document could not be opened", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + }, + } } fn add_document_panel_with_title( &mut self, - document: Document, + document: DocumentProjection, path: Option, title: Option, + runtime: flowstate_collab::crdt_runtime::CrdtRuntime, window: &mut Window, cx: &mut Context, ) { - self.create_document_panel(document, path, title, window, cx); - self.persist_temporary_workspace_session(cx); - cx.notify(); + match self.create_document_panel(document, path, title, DocumentRuntimeSource::Runtime(Box::new(runtime)), window, cx) { + Ok(_) => { + self.persist_temporary_workspace_session(cx); + cx.notify(); + }, + Err(error) => { + let detail = format!("The canonical Loro runtime could not be started: {error:#}"); + tracing::error!(error = %format_args!("{error:#}"), "creating document panel failed"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Document could not be opened", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + }, + } } fn create_flow_panel( @@ -692,6 +1209,9 @@ impl Workspace { else { return; }; + if self.close_collaboration_document_panel(panel_id, panel_kind.clone(), window, cx) { + return; + } if !panel_kind.is_dirty(cx) { self.remove_document_panel(panel_id, window, cx); return; @@ -732,8 +1252,12 @@ impl Workspace { } fn request_close_window(&mut self, window: &mut Window, cx: &mut Context) { + if self.request_close_window_with_collaboration(window, cx) { + return; + } let dirty_panels = self.dirty_panels(cx); if dirty_panels.is_empty() { + self.leave_all_collaboration_sessions(cx); window.remove_window(); return; } @@ -752,7 +1276,7 @@ impl Workspace { ); let window_handle = window.window_handle(); - cx.spawn(async move |_, cx| { + cx.spawn(async move |workspace, cx| { let should_close = match answer.await { Ok(0) => { let mut ok = true; @@ -782,7 +1306,10 @@ impl Workspace { }; if should_close { - let _ = window_handle.update(cx, |_, window, _| window.remove_window()); + let _ = window_handle.update(cx, |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| workspace.leave_all_collaboration_sessions(cx)); + window.remove_window(); + }); } }) .detach(); @@ -794,14 +1321,28 @@ impl Workspace { self.prompt_save_active_as(editor, window, cx); return; } + let panel_id = self.active_document_id; let save_task = editor.update(cx, |editor, cx| editor.save(cx)); let window_handle = window.window_handle(); - cx.spawn(async move |_, cx| { - if let Err(error) = save_task.await { - let detail = error.to_string(); - let _ = window_handle.update(cx, |_, window, cx| { - window.prompt(PromptLevel::Critical, "Save failed", Some(&detail), &[PromptButton::ok("Ok")], cx) - }); + cx.spawn(async move |workspace, cx| { + match save_task.await { + // A successful save checkpoints the runtime, recording a named + // revision into Loro. For a collaborating document that op must be + // advertised so peers converge promptly; for a solo document the + // refresh is a no-op because no session is registered for the panel. + Ok(()) => { + if let Some(panel_id) = panel_id { + let _ = workspace.update(cx, |_, cx| { + crate::collab::refresh_after_external_checkpoint(panel_id, cx); + }); + } + }, + Err(error) => { + let detail = error.to_string(); + let _ = window_handle.update(cx, |_, window, cx| { + window.prompt(PromptLevel::Critical, "Save failed", Some(&detail), &[PromptButton::ok("Ok")], cx) + }); + }, } }) .detach(); @@ -882,13 +1423,22 @@ impl Workspace { .insert(panel_id, generation); let save_task = editor.update(cx, |editor, cx| editor.save(cx)); cx.spawn(async move |workspace, cx| { - if let Err(error) = save_task.await { - eprintln!("autosave failed: {error}"); - let _ = workspace.update(cx, |workspace, _| { - if workspace.autosave_document_generations.get(&panel_id) == Some(&generation) { - workspace.autosave_document_generations.remove(&panel_id); - } - }); + match save_task.await { + // Keep collaborating peers in sync with the autosave checkpoint; a + // no-op for solo documents (no session registered for the panel). + Ok(()) => { + let _ = workspace.update(cx, |_, cx| { + crate::collab::refresh_after_external_checkpoint(panel_id, cx); + }); + }, + Err(error) => { + eprintln!("autosave failed: {error}"); + let _ = workspace.update(cx, |workspace, _| { + if workspace.autosave_document_generations.get(&panel_id) == Some(&generation) { + workspace.autosave_document_generations.remove(&panel_id); + } + }); + }, } }) .detach(); @@ -951,6 +1501,9 @@ impl Workspace { panel.update(cx, |panel, cx| panel.set_path(path, cx)); } workspace.persist_temporary_workspace_session(cx); + // Advertise the save checkpoint to collaborators (no-op when the + // panel has no active collaboration session). + crate::collab::refresh_after_external_checkpoint(panel_id, cx); cx.notify(); }); }, @@ -1152,9 +1705,153 @@ fn show_save_failed(window_handle: AnyWindowHandle, cx: &mut gpui::AsyncApp, det }); } +fn document_package_title_for_path(path: &Path) -> String { + path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "Flowstate Document".to_string()) +} + +fn write_bytes_to_path(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + atomicwrites::AtomicFile::new(path, atomicwrites::AllowOverwrite) + .write(|file| std::io::Write::write_all(file, bytes)) + .map_err(Into::into) +} + +fn flatten_runtime_edit_commands( + edits: Vec, +) -> ( + Vec, + Vec, + Option, +) { + let mut base_frontier = None; + let mut commands = Vec::new(); + let mut selection_after = None; + for edit in edits { + if !edit.semantic_commands.is_empty() { + if let Some(existing) = &base_frontier { + debug_assert_eq!(existing, &edit.base_frontier, "queued editor commands must share one projection frontier"); + } else { + base_frontier = Some(edit.base_frontier.clone()); + } + } + commands.extend(edit.semantic_commands); + if edit.selection_after.is_some() { + selection_after = edit.selection_after; + } + } + ( + base_frontier.unwrap_or_default(), + coalesce_document_runtime_commands(commands), + selection_after, + ) +} + +fn apply_local_runtime_events( + editor: &Entity, + events: Vec, + acknowledge_without_projection_replay: bool, + selection_after: Option, + cx: &mut Context, +) { + if acknowledge_without_projection_replay { + let frontier = events + .iter() + .filter_map(flowstate_collab::crdt_runtime::RuntimeEvent::frontier) + .next_back() + .map(ToOwned::to_owned); + editor.update(cx, |editor, cx| { + if let Some(frontier) = frontier { + // The visible projection already contains this local text/style batch. + // Replaying its runtime echo would shift the live caret twice and can + // overwrite keystrokes entered while the request was in flight. + editor.acknowledge_runtime_edit(frontier, None, cx); + } else { + editor.complete_runtime_edit(None, cx); + } + }); + return; + } + + let frontier = events + .iter() + .filter_map(flowstate_collab::crdt_runtime::RuntimeEvent::frontier) + .next_back() + .map(ToOwned::to_owned); + for event in events { + match event { + flowstate_collab::crdt_runtime::RuntimeEvent::ProjectionPatched { patches, frontier, .. } => { + editor.update(cx, |editor, cx| editor.apply_projection_patches_at_frontier(&patches, frontier, cx)); + }, + flowstate_collab::crdt_runtime::RuntimeEvent::ProjectionUpdated { document, .. } + | flowstate_collab::crdt_runtime::RuntimeEvent::RevisionOpened { document, .. } => { + editor.update(cx, |editor, cx| editor.replace_document_projection(*document, cx)); + }, + flowstate_collab::crdt_runtime::RuntimeEvent::LocalUpdate { .. } + | flowstate_collab::crdt_runtime::RuntimeEvent::RemoteUpdateApplied { .. } + | flowstate_collab::crdt_runtime::RuntimeEvent::RevisionForked { .. } => {}, + flowstate_collab::crdt_runtime::RuntimeEvent::SelectionRestored { selection } => { + editor.update(cx, |editor, cx| editor.restore_runtime_selection(selection, cx)); + }, + } + } + editor.update(cx, |editor, cx| { + if let Some(frontier) = frontier { + editor.complete_runtime_edit_replaying_pending(frontier, Vec::new(), selection_after, cx); + } else { + editor.complete_runtime_edit(selection_after, cx); + } + }); +} + +fn coalesce_document_runtime_commands( + commands: Vec, +) -> Vec { + let mut coalesced = Vec::new(); + for command in commands { + if let Some(previous) = coalesced.last_mut() + && merge_adjacent_runtime_insert_text(previous, &command) + { + continue; + } + coalesced.push(command); + } + coalesced +} + +fn merge_adjacent_runtime_insert_text( + previous: &mut crate::rich_text_element::SemanticEditCommand, + next: &crate::rich_text_element::SemanticEditCommand, +) -> bool { + let crate::rich_text_element::SemanticEditCommand::InsertText { at, text, styles } = previous else { + return false; + }; + let crate::rich_text_element::SemanticEditCommand::InsertText { + at: next_at, + text: next_text, + styles: next_styles, + } = next + else { + return false; + }; + if *styles != *next_styles || at.paragraph != next_at.paragraph || at.byte + text.len() != next_at.byte { + return false; + } + text.push_str(next_text); + true +} + enum LoadedWorkspaceDocument { Document { - document: Box, + document: Box, + runtime: Box, path: Option, title: Option, }, @@ -1175,6 +1872,7 @@ fn load_workspace_document(path: PathBuf) -> Result { - if let Err(error) = fs::write(&path, bytes) { + if let Err(error) = write_bytes_to_path(&path, &bytes) { eprintln!("failed to write temporary workspace session {}: {error}", path.display()); } }, @@ -1252,7 +1950,7 @@ fn persist_temporary_workspace_session_to_disk(session: TemporaryWorkspaceSessio } #[hotpath::measure] -fn recent_document_preview_document(document: &Document) -> Document { +fn recent_document_preview_document(document: &DocumentProjection) -> DocumentProjection { const MAX_PARAGRAPHS: usize = 12; const MAX_CHARS: usize = 2_200; diff --git a/crates/flowstate/src/workspace/workspace/keybindings.rs b/crates/flowstate/src/workspace/workspace/keybindings.rs index 83fd8507..5dcc715f 100644 --- a/crates/flowstate/src/workspace/workspace/keybindings.rs +++ b/crates/flowstate/src/workspace/workspace/keybindings.rs @@ -52,6 +52,18 @@ impl Workspace { self.close_active_document(window, cx); true }, + CommandId::ShareDocument => { + self.open_collaboration_dialog(window, cx); + true + }, + CommandId::JoinSession => { + self.open_join_collaboration_dialog(window, cx); + true + }, + CommandId::StartCollaboration => self.start_collaboration_on_active_document(cx).is_some(), + CommandId::CopyCollaborationTicket => self.copy_active_collaboration_ticket(window, cx), + CommandId::JoinCollaborationFromClipboard => self.join_collaboration_from_clipboard(window, cx), + CommandId::LeaveCollaboration => self.confirm_leave_collaboration_on_active_document(window, cx), CommandId::FindInDocument => self.open_active_document_search_bar(window, cx), CommandId::ZoomIn => { if let Some(editor) = self.active_editor.clone() { @@ -207,7 +219,10 @@ impl Workspace { } fn non_document_keybinding_surface_is_open(&self) -> bool { - self.settings_overlay.is_some() || self.file_search_overlay.is_some() + self.settings_overlay.is_some() + || self.file_search_overlay.is_some() + || self.collaboration_dialog.is_some() + || self.revision_dialog.is_some() } fn focused_workspace_input_is_focused(&self, window: &Window, cx: &App) -> bool { @@ -225,6 +240,14 @@ impl Workspace { .file_search_overlay .as_ref() .is_some_and(|overlay| overlay.read(cx).focus_handle(cx).is_focused(window)) + || self + .collaboration_dialog + .as_ref() + .is_some_and(|dialog| dialog.read(cx).focus_handle(cx).is_focused(window)) + || self + .revision_dialog + .as_ref() + .is_some_and(|dialog| dialog.read(cx).focus_handle(cx).is_focused(window)) || self .document_panels .iter() diff --git a/crates/flowstate/src/workspace/workspace/load.rs b/crates/flowstate/src/workspace/workspace/load.rs index 6884e225..482977ef 100644 --- a/crates/flowstate/src/workspace/workspace/load.rs +++ b/crates/flowstate/src/workspace/workspace/load.rs @@ -1,5 +1,6 @@ struct LoadedDocumentForOpen { - document: Document, + document: DocumentProjection, + runtime: flowstate_collab::crdt_runtime::CrdtRuntime, path: Option, title: Option, } @@ -8,22 +9,32 @@ struct LoadedDocumentForOpen { fn load_document_for_open(path: &PathBuf) -> std::io::Result { if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) { if extension.eq_ignore_ascii_case("docx") { - let (document, _) = convert_docx_to_document(path)?; + let title = path + .with_extension("db8") + .file_name() + .map(|name| name.to_string_lossy().to_string()); + let (imported, _) = import_docx_to_loro(path, title.as_deref().unwrap_or("Imported Document"))?; + let runtime = flowstate_collab::crdt_runtime::CrdtRuntime::from_imported_document(imported).map_err(runtime_io_error)?; + // Runtime startup records replica metadata and therefore advances the + // canonical frontier. The editor must start from that exact frontier or + // its first command is correctly rejected as stale. + let document = runtime.projection_snapshot().map_err(runtime_io_error)?; return Ok(LoadedDocumentForOpen { document, + runtime, path: None, - title: path - .with_extension("db8") - .file_name() - .map(|name| name.to_string_lossy().to_string()), + title, }); } if extension.eq_ignore_ascii_case("pdf") { let db8_bytes = crate::docx_conversion::extract_db8_bytes_from_pdf(path)? .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "PDF does not contain embedded Flowstate DB8 data"))?; - let document = flowstate_document::read_db8_bytes(&db8_bytes)?; + let package = flowstate_document::DocumentPackage::from_bytes(&db8_bytes)?; + let runtime = flowstate_collab::crdt_runtime::CrdtRuntime::from_package(package, None).map_err(runtime_io_error)?; + let document = runtime.projection_snapshot().map_err(runtime_io_error)?; return Ok(LoadedDocumentForOpen { document, + runtime, path: None, title: path .with_extension("db8") @@ -33,9 +44,49 @@ fn load_document_for_open(path: &PathBuf) -> std::io::Result { + let document = runtime.projection_snapshot().map_err(runtime_io_error)?; + (document, runtime) + }, + Err(error) if runtime_root_io_kind(&error) == Some(std::io::ErrorKind::NotFound) => { + let imported = flowstate_document::import_document_projection(new_blank_document(), "Flowstate Document")?; + let runtime = flowstate_collab::crdt_runtime::CrdtRuntime::from_imported_document(imported).map_err(runtime_io_error)?; + let document = runtime.projection_snapshot().map_err(runtime_io_error)?; + (document, runtime) + }, + Err(error) => return Err(runtime_io_error(error)), + }; + Ok(LoadedDocumentForOpen { document, + runtime, path: Some(path.clone()), title: None, }) } + +fn runtime_io_error(error: anyhow::Error) -> std::io::Error { + if let Some(io_error) = error.root_cause().downcast_ref::() { + return std::io::Error::new(io_error.kind(), error.to_string()); + } + std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()) +} + +fn runtime_root_io_kind(error: &anyhow::Error) -> Option { + error.root_cause().downcast_ref::().map(std::io::Error::kind) +} + +#[hotpath::measure] +fn load_document_preview(path: &Path) -> std::io::Result { + if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) { + if extension.eq_ignore_ascii_case("docx") { + return convert_docx_to_document(path).map(|(document, _)| document); + } + if extension.eq_ignore_ascii_case("pdf") { + let db8_bytes = crate::docx_conversion::extract_db8_bytes_from_pdf(path)? + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "PDF does not contain embedded Flowstate DB8 data"))?; + return flowstate_document::read_db8_bytes(&db8_bytes); + } + } + flowstate_document::read_db8(path) +} diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index 230b9d30..8d05d2a5 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -11,8 +11,8 @@ use std::{ use gpui::{ AnyElement, AnyWindowHandle, App, Context, Corner, DismissEvent, DummyKeyboardMapper, Entity, Focusable, Hsla, InteractiveElement, IntoElement, KeyBinding, Keystroke, MouseButton, NoAction, PathPromptOptions, Pixels, Point, PromptButton, PromptLevel, Render, ScrollHandle, - SharedString, Subscription, WeakEntity, Window, WindowBounds, WindowDecorations, WindowOptions, anchored, black, deferred, div, prelude::*, - px, + SharedString, Subscription, Timer, WeakEntity, Window, WindowBounds, WindowDecorations, WindowOptions, anchored, black, deferred, div, + prelude::*, px, }; #[cfg(target_os = "windows")] use gpui::{Bounds, size}; @@ -31,22 +31,22 @@ use gpui_component::tab::{Tab, TabBar}; use gpui_component::tree::{TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme as _, Colorize as _, Disableable, Icon, IconName, PixelsExt, Root, Selectable, Sizable, Theme, ThemeRegistry, TitleBar, - VirtualListScrollHandle, h_flex, v_flex, + VirtualListScrollHandle, WindowExt as _, h_flex, v_flex, }; use uuid::Uuid; use crate::app_settings::{ - load_autosave, load_document_theme, load_recent_documents, load_send_custom_directory, load_send_to_document_directory, - load_smart_word_selection, load_tub_root, save_autosave, save_document_theme, save_recent_documents, save_send_custom_directory, - save_send_to_document_directory, save_smart_word_selection, save_theme_name, + load_autosave, load_document_theme, load_local_user_identity, load_recent_documents, load_send_custom_directory, + load_send_to_document_directory, load_smart_word_selection, load_tub_root, save_autosave, save_document_theme, save_recent_documents, + save_send_custom_directory, save_send_to_document_directory, save_smart_word_selection, save_theme_name, }; use crate::commands::CommandId; -use crate::docx_conversion::convert_docx_to_document; +use crate::docx_conversion::{convert_docx_to_document, import_docx_to_loro}; use crate::flow::{FlowEditor, FlowPanel}; use crate::rich_text_element::{ - ArmedInlineTool, CustomParagraphBorder, Document, DocumentTheme, InputParagraph, InputRun, ParagraphStyle, RichTextDocumentElement, + ArmedInlineTool, CustomParagraphBorder, DocumentProjection, DocumentTheme, InputParagraph, InputRun, ParagraphStyle, RichTextDocumentElement, RichTextEditor, Save, SectionKind, ThemeUnderline, ZoomIn, ZoomOut, document_from_input, document_text_slice, flowstate_document_theme, - load_or_create_document, paragraph_byte_range, paragraph_index_for_id, + paragraph_byte_range, paragraph_index_for_id, }; use crate::workspace::document_panel::DocumentPanel; use crate::workspace::file_management::{ @@ -58,12 +58,15 @@ use flowstate_tub::{SearchHit, SearchUnitKind, TubFile, TubIndex, TubTreeNode}; pub(super) const APP_CHROME_BORDER_WIDTH: Pixels = px(1.0); const SIDE_PANEL_COLLAPSED_WIDTH: Pixels = px(30.0); +const DOCUMENT_RUNTIME_FLUSH_DEBOUNCE_MS: u64 = 24; #[path = "../toolkit_panel.rs"] mod toolkit_panel; pub struct Workspace { document_panels: Vec>, + document_runtimes: HashMap, + document_runtime_flush_pending: HashSet, flow_panels: Vec>, active_document_id: Option, active_editor: Option>, @@ -73,7 +76,7 @@ pub struct Workspace { toolkit_collapsed: bool, active_toolkit_tool: Option, recent_documents: Vec, - recent_document_previews: HashMap, + recent_document_previews: HashMap, recent_document_preview_generation: u64, temporary_workspace_session_pending: Option, temporary_workspace_session_persist_scheduled: bool, @@ -103,6 +106,10 @@ pub struct Workspace { autosave_enabled: bool, autosave_document_generations: HashMap, autosave_flow_in_flight: HashSet, + collaboration_dialog: Option>, + revision_dialog: Option>, + collab_notice_subscriptions: HashMap, + collab_incompatible_version_notices: HashSet, file_search_overlay: Option>, tub_root: Option, tub_index: Option>, @@ -261,6 +268,8 @@ impl DocumentStyleSection { } include!("documents.rs"); +include!("collab_prompts.rs"); +include!("collab.rs"); include!("workspace_state.rs"); include!("load.rs"); include!("traits.rs"); diff --git a/crates/flowstate/src/workspace/workspace/outline.rs b/crates/flowstate/src/workspace/workspace/outline.rs index d4e6602f..6f3bea83 100644 --- a/crates/flowstate/src/workspace/workspace/outline.rs +++ b/crates/flowstate/src/workspace/workspace/outline.rs @@ -42,7 +42,7 @@ impl OutlineCache { self.visible_revision = revision; } - fn update_signature(&mut self, document: &Document, edit_generation: u64) -> bool { + fn update_signature(&mut self, document: &DocumentProjection, edit_generation: u64) -> bool { let paragraph_count = outline_signature_entries_into(document, &mut self.signature_scratch); let unchanged = self.signature.paragraph_count == paragraph_count && self.signature.entries == self.signature_scratch; self.signature.paragraph_count = paragraph_count; @@ -119,7 +119,7 @@ fn outline_node_to_tree_item(node: &OutlineNode, collapsed_items: &HashSet Vec { +fn outline_nodes(document: &DocumentProjection) -> Vec { let signature = outline_signature(document); outline_nodes_from_entries(&signature.entries) } @@ -143,14 +143,14 @@ fn outline_nodes_from_entries(entries: &[OutlineEntry]) -> Vec { } #[hotpath::measure] -fn outline_signature(document: &Document) -> OutlineSignature { +fn outline_signature(document: &DocumentProjection) -> OutlineSignature { let mut entries = Vec::new(); let paragraph_count = outline_signature_entries_into(document, &mut entries); OutlineSignature { paragraph_count, entries } } #[hotpath::measure] -fn outline_signature_entries_into(document: &Document, entries: &mut Vec) -> usize { +fn outline_signature_entries_into(document: &DocumentProjection, entries: &mut Vec) -> usize { let mut entry_ix = 0usize; for (paragraph_ix, paragraph) in document.paragraphs.iter().enumerate() { let Some(level) = outline_level(paragraph.style) else { @@ -264,14 +264,14 @@ fn outline_paragraph_ix(id: &str) -> Option { #[cfg(test)] #[hotpath::measure] -fn outline_paragraph_label(document: &Document, paragraph_ix: usize) -> String { +fn outline_paragraph_label(document: &DocumentProjection, paragraph_ix: usize) -> String { let mut label = String::with_capacity(80); outline_paragraph_label_into(document, paragraph_ix, &mut label); label } #[hotpath::measure] -fn outline_paragraph_label_into(document: &Document, paragraph_ix: usize, label: &mut String) { +fn outline_paragraph_label_into(document: &DocumentProjection, paragraph_ix: usize, label: &mut String) { let paragraph_range = paragraph_byte_range(document, paragraph_ix); const MAX_BYTES: usize = 80; const TRUNCATED_BYTES: usize = MAX_BYTES - 3; diff --git a/crates/flowstate/src/workspace/workspace/render_documents.rs b/crates/flowstate/src/workspace/workspace/render_documents.rs index 187be03c..fd0b30bb 100644 --- a/crates/flowstate/src/workspace/workspace/render_documents.rs +++ b/crates/flowstate/src/workspace/workspace/render_documents.rs @@ -75,6 +75,7 @@ impl Workspace { .children(tabs.into_iter().map(|tab| { let panel_id = tab.id; let workspace = workspace.clone(); + let collab_phase = crate::collab::phase_for_panel(panel_id, cx); let tab_prefix = h_flex() .ml(px(5.0)) .mr(px(-3.0)) @@ -104,6 +105,9 @@ impl Workspace { .border_color(cx.theme().warning.opacity(0.72)) .child(pin_label), ) + }) + .when_some(collab_phase.as_ref().and_then(|phase| crate::collab::status::tab_badge(phase, cx)), |this, badge| { + this.child(badge) }); let close_button = icon_button(("close-tab", panel_id.as_u128() as u64), AppIcon::Close) .tooltip("Close document") diff --git a/crates/flowstate/src/workspace/workspace/render_status.rs b/crates/flowstate/src/workspace/workspace/render_status.rs index 7fede050..6ffc6501 100644 --- a/crates/flowstate/src/workspace/workspace/render_status.rs +++ b/crates/flowstate/src/workspace/workspace/render_status.rs @@ -232,6 +232,8 @@ impl Workspace { } fn render_status_bar(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync_collab_notice_subscriptions(window, cx); + let speech_word_count = if let (Some(document_id), Some(editor)) = (self.active_document_id, self.active_editor.as_ref()) { let editor = editor.read(cx); let generation = editor.edit_generation(); @@ -264,6 +266,9 @@ impl Workspace { None }; let zoom = self.active_editor.as_ref().map(|editor| editor.read(cx).zoom_percent()); + let collab_phase = self + .active_document_id + .and_then(|panel_id| crate::collab::phase_for_panel(panel_id, cx)); if let Some(percent) = zoom { self.sync_zoom_slider(percent, window, cx); } @@ -277,6 +282,19 @@ impl Workspace { .border_color(cx.theme().border) .bg(cx.theme().background) .child(div().flex_1()) + .when_some(collab_phase, |this, phase| { + if matches!(phase, crate::collab::SessionPhase::Detached(_)) { + this + } else { + this.child( + Button::new("collaboration-status-pill") + .text() + .compact() + .child(crate::collab::status::status_pill(&phase, cx)) + .on_click(cx.listener(|workspace, _, window, cx| workspace.open_collaboration_dialog(window, cx))), + ) + } + }) .when_some(zoom, |this, percent| this.child(self.render_zoom_slider(percent, cx))) .when_some(speech_word_count, |this, count| { this.child( @@ -289,9 +307,46 @@ impl Workspace { ) }) } + + fn sync_collab_notice_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { + let mut active_sessions = HashSet::new(); + let window_handle = window.window_handle(); + + for panel in &self.document_panels { + let panel_id = panel.read(cx).id(); + let Some(session) = crate::collab::session_for_panel(panel_id, cx) else { + continue; + }; + let session_id = session.read(cx).session_id(); + active_sessions.insert(session_id); + if self.collab_notice_subscriptions.contains_key(&session_id) { + continue; + } + + let notice_window_handle = window_handle; + let subscription = cx.subscribe(&session, move |workspace, _, notice: &crate::collab::SessionNotice, cx| { + let notice = notice.clone(); + if let crate::collab::SessionNotice::IncompatibleVersion(peer) = ¬ice + && !workspace.collab_incompatible_version_notices.insert(peer.clone()) + { + return; + } + if let Err(error) = notice_window_handle.update(cx, |_, window, cx| { + crate::collab::notify::show_session_notice(¬ice, window, cx); + }) { + tracing::warn!("showing collaboration notification failed: {error}"); + } + }); + self.collab_notice_subscriptions.insert(session_id, subscription); + } + + self + .collab_notice_subscriptions + .retain(|session_id, _| active_sessions.contains(session_id)); + } } -fn speech_word_count(document: &Document) -> usize { +fn speech_word_count(document: &DocumentProjection) -> usize { document .paragraphs .iter() diff --git a/crates/flowstate/src/workspace/workspace/render_top_bar.rs b/crates/flowstate/src/workspace/workspace/render_top_bar.rs index 0990071f..a39caaff 100644 --- a/crates/flowstate/src/workspace/workspace/render_top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/render_top_bar.rs @@ -2,6 +2,10 @@ impl Workspace { fn render_top_bar(&mut self, _window: &Window, cx: &mut Context) -> impl IntoElement { let workspace = cx.entity().downgrade(); + let active_collaborating = self + .active_document_id + .and_then(|panel_id| crate::collab::phase_for_panel(panel_id, cx)) + .is_some_and(|phase| !matches!(phase, crate::collab::SessionPhase::Detached(_))); TitleBar::new() .on_close_window(move |_, window, cx| { let _ = workspace.update(cx, |workspace, cx| workspace.request_close_window(window, cx)); @@ -16,12 +20,15 @@ impl Workspace { .child(file_top_bar_button(self.active_document_id.is_some(), cx)) .child(insert_top_bar_button(cx, self.active_editor.is_some())) .child(document_top_bar_button(cx)) + .child(collaboration_top_bar_button(cx, self.active_document_id.is_some(), active_collaborating)) .child(view_top_bar_button( cx, !self.outline_collapsed, !self.ribbon_collapsed, !self.toolkit_collapsed, )) + .child(div().flex_1()) + .child(share_top_bar_button(cx, self.active_editor.is_some(), active_collaborating)) .child(settings_top_bar_button(cx)), ) } diff --git a/crates/flowstate/src/workspace/workspace/top_bar.rs b/crates/flowstate/src/workspace/workspace/top_bar.rs index d9397c6e..c189aabb 100644 --- a/crates/flowstate/src/workspace/workspace/top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/top_bar.rs @@ -106,6 +106,42 @@ fn document_top_bar_button(cx: &mut Context) -> impl IntoElement { ) } +fn collaboration_top_bar_button(cx: &mut Context, has_document: bool, active_collaborating: bool) -> impl IntoElement { + let workspace = cx.entity().downgrade(); + div() + .h_full() + .flex_none() + .flex() + .items_center() + .justify_center() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Button::new("top-collaborate") + .label("Collaborate") + .xsmall() + .ghost() + .dropdown_menu(move |menu, _, _| { + menu + .item(file_menu_item(workspace.clone(), "Share / Collaborate...", !has_document, |workspace, window, cx| { + workspace.open_collaboration_dialog(window, cx); + })) + .item(file_menu_item(workspace.clone(), "Copy Invite Ticket", !has_document, |workspace, window, cx| { + workspace.copy_active_collaboration_ticket(window, cx); + })) + .separator() + .item(file_menu_item(workspace.clone(), "Join Session...", false, |workspace, window, cx| { + workspace.open_join_collaboration_dialog(window, cx); + })) + .item(file_menu_item(workspace.clone(), "Join from Clipboard", false, |workspace, window, cx| { + workspace.join_collaboration_from_clipboard(window, cx); + })) + .item(file_menu_item(workspace.clone(), "Leave Shared Session", !active_collaborating, |workspace, window, cx| { + workspace.confirm_leave_collaboration_on_active_document(window, cx); + })) + }), + ) +} + #[hotpath::measure] fn file_top_bar_button(has_document: bool, cx: &mut Context) -> impl IntoElement { let workspace = cx.entity().downgrade(); @@ -143,6 +179,13 @@ fn file_top_bar_button(has_document: bool, cx: &mut Context) -> impl workspace.save_active_as(window, cx); })) .separator() + .item(file_menu_item(workspace.clone(), "Share Document...", !has_document, |workspace, window, cx| { + workspace.open_collaboration_dialog(window, cx); + })) + .item(file_menu_item(workspace.clone(), "Join Session...", false, |workspace, window, cx| { + workspace.open_join_collaboration_dialog(window, cx); + })) + .separator() .item(file_menu_item(workspace.clone(), "Close File", !has_document, |workspace, window, cx| { workspace.close_active_document(window, cx); })) @@ -213,6 +256,38 @@ fn insert_top_bar_button(cx: &mut Context, has_document: bool) -> imp ) } +#[hotpath::measure] +fn share_top_bar_button(cx: &mut Context, has_document: bool, collaborating: bool) -> impl IntoElement { + let workspace = cx.entity().downgrade(); + let button = Button::new("top-share-document") + .label("Share") + .xsmall() + .ghost() + .disabled(!has_document) + .tooltip("Share / Collaborate") + .on_click(move |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| workspace.open_collaboration_dialog(window, cx)); + }); + + div() + .h_full() + .flex_none() + .flex() + .items_center() + .justify_center() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(if collaborating { + button.custom( + ButtonCustomVariant::new(cx) + .foreground(cx.theme().success) + .hover(cx.theme().success.opacity(0.12)) + .active(cx.theme().success.opacity(0.18)), + ) + } else { + button + }) +} + #[hotpath::measure] fn insert_image_from_top_bar(workspace: &WeakEntity, cx: &mut App) { let _ = workspace.update(cx, |workspace, cx| { diff --git a/crates/flowstate/src/workspace/workspace/traits.rs b/crates/flowstate/src/workspace/workspace/traits.rs index 4111d93b..62b1a21a 100644 --- a/crates/flowstate/src/workspace/workspace/traits.rs +++ b/crates/flowstate/src/workspace/workspace/traits.rs @@ -74,5 +74,7 @@ impl Render for Workspace { .with_priority(1), ) }) + .when_some(Root::render_dialog_layer(window, cx), |this, layer| this.child(layer)) + .when_some(Root::render_notification_layer(window, cx), |this, layer| this.child(layer)) } } diff --git a/crates/flowstate/src/workspace/workspace/window.rs b/crates/flowstate/src/workspace/workspace/window.rs index 62829fb0..af19175a 100644 --- a/crates/flowstate/src/workspace/workspace/window.rs +++ b/crates/flowstate/src/workspace/workspace/window.rs @@ -9,8 +9,92 @@ pub fn install_workspace_close_prompt(workspace: Entity, window: &mut return true; } + let collaboration_panels = workspace.read(cx).collaboration_close_panels(cx); + if !collaboration_panels.is_empty() { + if prompt_open.get() { + return false; + } + prompt_open.set(true); + + let collaboration_ids = collaboration_panels + .iter() + .map(|panel| panel.id) + .collect::>(); + let dirty_panels = workspace + .read(cx) + .dirty_panels(cx) + .into_iter() + .filter(|panel| !collaboration_ids.contains(&panel.panel_id(cx))) + .collect::>(); + let answer = if collaboration_panels.len() == 1 { + let phase = crate::collab::phase_for_panel(collaboration_panels[0].id, cx); + window.prompt( + PromptLevel::Warning, + "Leave the collaboration session and quit?", + Some(&collaboration_leave_detail(phase.as_ref())), + &[PromptButton::ok("Leave"), PromptButton::cancel("Cancel")], + cx, + ) + } else { + let detail = format!( + "You're in {} collaboration sessions. Leave all and quit?", + collaboration_panels.len() + ); + window.prompt( + PromptLevel::Warning, + "Leave collaboration sessions?", + Some(&detail), + &[PromptButton::ok("Leave"), PromptButton::cancel("Cancel")], + cx, + ) + }; + let prompt_open = prompt_open.clone(); + let allow_close = allow_close.clone(); + let workspace = workspace.clone(); + + cx.spawn(async move |cx| { + let mut should_close = false; + if matches!(answer.await, Ok(0)) { + let mut discards = Vec::new(); + let mut ok = true; + for panel in collaboration_panels { + match resolve_collaboration_save(panel.panel, panel.save_prompt, window_handle, cx).await { + CollaborationCloseResolution::Proceed { discard } => { + if let Some(discard) = discard { + discards.push(discard); + } + }, + CollaborationCloseResolution::Cancelled => { + ok = false; + break; + }, + } + } + if ok && resolve_dirty_window_close(dirty_panels, window_handle, cx).await { + for panel in discards { + panel.discard(cx); + } + should_close = true; + } + } + + prompt_open.set(false); + if should_close { + allow_close.set(true); + let _ = window_handle.update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| workspace.leave_all_collaboration_sessions(cx)); + window.remove_window(); + }); + } + }) + .detach(); + + return false; + } + let dirty_panels = workspace.read(cx).dirty_panels(cx); if dirty_panels.is_empty() { + workspace.update(cx, |workspace, cx| workspace.leave_all_collaboration_sessions(cx)); return true; } @@ -33,6 +117,7 @@ pub fn install_workspace_close_prompt(workspace: Entity, window: &mut ); let prompt_open = prompt_open.clone(); let allow_close = allow_close.clone(); + let workspace = workspace.clone(); cx.spawn(async move |cx| { let should_close = match answer.await { @@ -65,6 +150,7 @@ pub fn install_workspace_close_prompt(workspace: Entity, window: &mut prompt_open.set(false); if should_close { + let _ = workspace.update(cx, |workspace, cx| workspace.leave_all_collaboration_sessions(cx)); allow_close.set(true); let _ = window_handle.update(cx, |_, window, _| window.remove_window()); } diff --git a/crates/flowstate/src/workspace/workspace/workspace_state.rs b/crates/flowstate/src/workspace/workspace/workspace_state.rs index b54807c1..61ffd319 100644 --- a/crates/flowstate/src/workspace/workspace/workspace_state.rs +++ b/crates/flowstate/src/workspace/workspace/workspace_state.rs @@ -733,7 +733,7 @@ fn card_paragraph_excluded_from_condense(paragraph: &InputParagraph) -> bool { } fn selected_fragment_or_enclosing_section( - document: &Document, + document: &DocumentProjection, selection: &crate::rich_text_element::EditorSelection, ) -> crate::rich_text_element::RichClipboardFragment { if selection.anchor != selection.head { @@ -763,7 +763,7 @@ fn selected_fragment_or_enclosing_section( ) } -fn enclosing_section_bounds(document: &Document, paragraph_ix: usize, section_slots: &[u8]) -> Option<(usize, usize)> { +fn enclosing_section_bounds(document: &DocumentProjection, paragraph_ix: usize, section_slots: &[u8]) -> Option<(usize, usize)> { document .sections .iter() diff --git a/crates/gpui-flowtext/Cargo.toml b/crates/gpui-flowtext/Cargo.toml index 59b84aa0..bc715c6c 100644 --- a/crates/gpui-flowtext/Cargo.toml +++ b/crates/gpui-flowtext/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" [lints] workspace = true [dependencies] +blake3 = "1.8.5" crop = { workspace = true } gpui = { workspace = true } gpui-component = { workspace = true } @@ -31,6 +32,8 @@ uuid = { workspace = true } [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] cosmic-text = { workspace = true } +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } [features] hotpath = ["hotpath/hotpath"] diff --git a/crates/gpui-flowtext/README.md b/crates/gpui-flowtext/README.md index 729b45ae..fbcfae50 100644 --- a/crates/gpui-flowtext/README.md +++ b/crates/gpui-flowtext/README.md @@ -26,7 +26,7 @@ This project is early and actively being extracted from a production GPUI applic - DOCX/PDF exporters by default - A prescribed file extension for host applications -The default native extension is `gptx`, but applications can use their own extension when exporting or saving native documents. +Native persistence is supplied by the host application. Flowstate uses its Loro-native `.db8` package adapter. ## API Tiers diff --git a/crates/gpui-flowtext/src/api.rs b/crates/gpui-flowtext/src/api.rs index 6d2694d9..9ee5e895 100644 --- a/crates/gpui-flowtext/src/api.rs +++ b/crates/gpui-flowtext/src/api.rs @@ -2,7 +2,9 @@ use std::{borrow::Cow, path::Path, sync::Arc}; use gpui::Pixels; -use crate::{Document, DocumentExportFormat, EditorSelection, HighlightStyle, ParagraphStyle, RichTextEditorCommand, RunSemanticStyle}; +use crate::{ + DocumentExportFormat, DocumentProjection, EditorSelection, HighlightStyle, ParagraphStyle, RichTextEditorCommand, RunSemanticStyle, +}; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct StyleId(pub Cow<'static, str>); @@ -120,10 +122,10 @@ pub trait AssetResolver: Send + Sync + 'static { } pub trait DocumentSerializer: Send + Sync + 'static { - fn read(&self, bytes: &[u8]) -> std::io::Result; - fn write(&self, document: &Document) -> std::io::Result>; + fn read(&self, bytes: &[u8]) -> std::io::Result; + fn write(&self, document: &DocumentProjection) -> std::io::Result>; } pub trait ExternalFormatExporter: Send + Sync + 'static { - fn write_external_format(&self, output_path: &Path, document: &Document, format: DocumentExportFormat) -> std::io::Result<()>; + fn write_external_format(&self, output_path: &Path, document: &DocumentProjection, format: DocumentExportFormat) -> std::io::Result<()>; } diff --git a/crates/gpui-flowtext/src/collaboration.rs b/crates/gpui-flowtext/src/collaboration.rs index 308b31a8..8c562532 100644 --- a/crates/gpui-flowtext/src/collaboration.rs +++ b/crates/gpui-flowtext/src/collaboration.rs @@ -1,56 +1,36 @@ use std::ops::Range; -use serde::{Deserialize, Serialize}; +use super::{ + AssetId, AssetRecord, BlockId, DocumentOffset, DocumentProjection, DocumentSpan, EditorSelection, InputBlock, InputBlockAlignment, + InputImageSizing, InputParagraph, InputTableCell, InputTableColumnWidth, InputTableRow, ParagraphId, ParagraphStyle, RunStyles, TextRun, +}; +use rustc_hash::FxHashMap; -use super::{Block, BlockId, Document, DocumentSpan, ParagraphId, ParagraphStyle, RunStyles, new_block_id, new_paragraph_id}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct TableCellId(pub u128); +const OBJECT_REPLACEMENT: char = '\u{FFFC}'; #[derive(Clone, Debug, Default)] pub struct DocumentIdentityMap { paragraph_ids: Vec, block_ids: Vec, - table_cell_ids: Vec>>, + paragraph_index_by_id: FxHashMap, } #[hotpath::measure_all] impl DocumentIdentityMap { #[must_use] - pub fn new(document: &Document) -> Self { + pub fn new(document: &DocumentProjection) -> Self { let mut this = Self::default(); this.reconcile(document); this } - pub fn reconcile(&mut self, document: &Document) { + pub fn reconcile(&mut self, document: &DocumentProjection) { self.paragraph_ids.clone_from(&document.ids.paragraph_ids); - self.block_ids.clone_from(&document.ids.block_ids); - self - .table_cell_ids - .resize_with(document.blocks.len(), Vec::new); - self.table_cell_ids.truncate(document.blocks.len()); - for (block_ix, block) in document.blocks.iter().enumerate() { - let Block::Table(table) = block else { - self.table_cell_ids[block_ix].clear(); - continue; - }; - let rows = &mut self.table_cell_ids[block_ix]; - rows.resize_with(table.rows.len(), Vec::new); - rows.truncate(table.rows.len()); - for (row_ix, row) in table.rows.iter().enumerate() { - resize_ids(&mut rows[row_ix], row.cells.len(), TableCellId); - } + self.paragraph_index_by_id.clear(); + for (ix, id) in self.paragraph_ids.iter().enumerate() { + self.paragraph_index_by_id.insert(*id, ix); } - } - - pub fn insert_split_paragraph(&mut self, paragraph_ix: usize, block_ix: usize) { - self - .paragraph_ids - .insert((paragraph_ix + 1).min(self.paragraph_ids.len()), new_paragraph_id()); - let block_insert_ix = (block_ix + 1).min(self.block_ids.len()); - self.block_ids.insert(block_insert_ix, new_block_id()); - self.table_cell_ids.insert(block_insert_ix, Vec::new()); + self.block_ids.clone_from(&document.ids.block_ids); } #[must_use] @@ -63,54 +43,25 @@ impl DocumentIdentityMap { self.block_ids.get(block_ix).copied() } - #[must_use] - pub fn table_cell_id(&self, block_ix: usize, row_ix: usize, cell_ix: usize) -> Option { - self - .table_cell_ids - .get(block_ix)? - .get(row_ix)? - .get(cell_ix) - .copied() - } - #[must_use] pub fn paragraph_index(&self, id: ParagraphId) -> Option { - self - .paragraph_ids - .iter() - .position(|candidate| *candidate == id) - } -} - -#[hotpath::measure] -fn resize_ids(ids: &mut Vec, len: usize, wrap: impl Fn(u128) -> T) -where - T: std::marker::Copy, -{ - while ids.len() < len { - ids.push(wrap(uuid::Uuid::new_v4().as_u128())); + self.paragraph_index_by_id.get(&id).copied() } - ids.truncate(len); } #[derive(Clone, Debug)] -pub enum CanonicalOperation { +pub enum SemanticEditCommand { InsertText { - paragraph: ParagraphId, - byte: usize, + at: DocumentOffset, text: String, styles: RunStyles, }, DeleteRange { - start_paragraph: ParagraphId, - start_byte: usize, - end_paragraph: ParagraphId, - end_byte: usize, + range: Range, }, SplitParagraph { - paragraph: ParagraphId, - byte: usize, - new_paragraph: ParagraphId, + at: DocumentOffset, + inherited_style: ParagraphStyle, }, JoinParagraphs { first: ParagraphId, @@ -128,6 +79,7 @@ pub enum CanonicalOperation { InsertBlock { block: BlockId, block_ix: usize, + after: InputBlock, }, DeleteBlock { block: BlockId, @@ -137,169 +89,151 @@ pub enum CanonicalOperation { new_block_ix: usize, }, ReplaceParagraphSpan { - start_paragraph: Option, + start: Option, before: DocumentSpan, after: DocumentSpan, }, ReplaceBlock { block: Option, - }, - ReplaceDocument, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -enum WireCanonicalOperation { - InsertText { - paragraph: ParagraphId, - byte: usize, + block_ix: usize, + after: InputBlock, + }, + InsertTableRow { + table: BlockId, + row_ix: usize, + row: InputTableRow, + }, + DeleteTableRow { + table: BlockId, + row_ix: usize, + }, + MoveTableRow { + table: BlockId, + from_row_ix: usize, + to_row_ix: usize, + }, + InsertTableColumn { + table: BlockId, + column_ix: usize, + width: InputTableColumnWidth, + cells: Vec, + }, + DeleteTableColumn { + table: BlockId, + column_ix: usize, + }, + MoveTableColumn { + table: BlockId, + from_column_ix: usize, + to_column_ix: usize, + }, + ReplaceTableCell { + table: BlockId, + row_ix: usize, + cell_ix: usize, + cell: InputTableCell, + }, + SetTableCellSpan { + table: BlockId, + row_ix: usize, + cell_ix: usize, + row_span: u16, + column_span: u16, + }, + ReplaceEquationSourceRange { + equation: BlockId, + range: Range, text: String, - styles: RunStyles, }, - DeleteRange { - start_paragraph: ParagraphId, - start_byte: usize, - end_paragraph: ParagraphId, - end_byte: usize, - }, - SplitParagraph { - paragraph: ParagraphId, - byte: usize, - new_paragraph: ParagraphId, + ReplaceImageAltText { + image: BlockId, + text: String, }, - JoinParagraphs { - first: ParagraphId, - second: ParagraphId, + ReplaceImageCaption { + image: BlockId, + caption: Option, }, - SetParagraphStyle { - paragraph: ParagraphId, - style: ParagraphStyle, + SetImageLayout { + image: BlockId, + sizing: InputImageSizing, + alignment: InputBlockAlignment, }, - SetRunStyles { - paragraph: ParagraphId, - range: Range, - styles: RunStyles, + SetTableColumnWidth { + table: BlockId, + column_ix: usize, + width: InputTableColumnWidth, }, } -impl WireCanonicalOperation { - fn from_canonical(operation: &CanonicalOperation) -> Option { - match operation { - CanonicalOperation::InsertText { - paragraph, - byte, - text, - styles, - } => Some(Self::InsertText { - paragraph: *paragraph, - byte: *byte, - text: text.clone(), - styles: *styles, - }), - CanonicalOperation::DeleteRange { - start_paragraph, - start_byte, - end_paragraph, - end_byte, - } => Some(Self::DeleteRange { - start_paragraph: *start_paragraph, - start_byte: *start_byte, - end_paragraph: *end_paragraph, - end_byte: *end_byte, - }), - CanonicalOperation::SplitParagraph { - paragraph, - byte, - new_paragraph, - } => Some(Self::SplitParagraph { - paragraph: *paragraph, - byte: *byte, - new_paragraph: *new_paragraph, - }), - CanonicalOperation::JoinParagraphs { first, second } => Some(Self::JoinParagraphs { - first: *first, - second: *second, - }), - CanonicalOperation::SetParagraphStyle { paragraph, style } => Some(Self::SetParagraphStyle { - paragraph: *paragraph, - style: *style, - }), - CanonicalOperation::SetRunStyles { paragraph, range, styles } => Some(Self::SetRunStyles { - paragraph: *paragraph, - range: range.clone(), - styles: *styles, - }), - CanonicalOperation::InsertBlock { .. } - | CanonicalOperation::DeleteBlock { .. } - | CanonicalOperation::MoveBlock { .. } - | CanonicalOperation::ReplaceParagraphSpan { .. } - | CanonicalOperation::ReplaceBlock { .. } - | CanonicalOperation::ReplaceDocument => None, - } - } - - fn into_canonical(self) -> CanonicalOperation { +impl SemanticEditCommand { + /// Whether the editor's optimistic projection is already the exact visible + /// result of this command and can be acknowledged without replaying the + /// runtime's projection echo. + #[must_use] + pub fn can_acknowledge_without_projection_replay(&self) -> bool { match self { - Self::InsertText { - paragraph, - byte, - text, - styles, - } => CanonicalOperation::InsertText { - paragraph, - byte, - text, - styles, - }, - Self::DeleteRange { - start_paragraph, - start_byte, - end_paragraph, - end_byte, - } => CanonicalOperation::DeleteRange { - start_paragraph, - start_byte, - end_paragraph, - end_byte, - }, - Self::SplitParagraph { - paragraph, - byte, - new_paragraph, - } => CanonicalOperation::SplitParagraph { - paragraph, - byte, - new_paragraph, - }, - Self::JoinParagraphs { first, second } => CanonicalOperation::JoinParagraphs { first, second }, - Self::SetParagraphStyle { paragraph, style } => CanonicalOperation::SetParagraphStyle { paragraph, style }, - Self::SetRunStyles { paragraph, range, styles } => CanonicalOperation::SetRunStyles { paragraph, range, styles }, + Self::InsertText { text, .. } => !text.contains('\n') && !text.contains(OBJECT_REPLACEMENT), + Self::DeleteRange { range } => range.start.paragraph == range.end.paragraph, + Self::SetParagraphStyle { .. } | Self::SetRunStyles { .. } => true, + _ => false, } } } -pub fn encode_canonical_operations(operations: &[CanonicalOperation]) -> Option> { - let wire_operations: Vec<_> = operations - .iter() - .filter_map(WireCanonicalOperation::from_canonical) - .collect(); - if wire_operations.is_empty() && !operations.is_empty() { - return None; - } - postcard::to_stdvec(&wire_operations).ok() +#[derive(Clone, Debug, Default)] +pub struct SemanticCommandBatch { + pub base_frontier: Vec, + pub semantic_commands: Vec, + pub selection_after: Option, } -pub fn decode_canonical_operations(bytes: &[u8]) -> Option> { - postcard::from_bytes::>(bytes) - .ok() - .map(|operations| { - operations - .into_iter() - .map(WireCanonicalOperation::into_canonical) - .collect() - }) +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectionTextDelta { + Retain(usize), + Insert(usize), + Delete(usize), } -#[derive(Clone, Debug, Default)] -pub struct CollaborationEdit { - pub operations: Vec, +#[derive(Clone, Debug)] +pub struct ProjectionStructuralBlock { + pub block_id: BlockId, + pub paragraph_id: Option, + pub block: InputBlock, +} + +#[derive(Clone, Debug)] +pub enum ProjectionPatch { + ParagraphText { + row: usize, + new: InputParagraph, + delta_utf8: Vec, + }, + ParagraphStyle { + row: usize, + style: ParagraphStyle, + }, + ParagraphRuns { + row: usize, + runs: Vec, + }, + ReplaceObjectBlock { + row: usize, + block: ProjectionStructuralBlock, + }, + InsertBlocks { + row: usize, + blocks: Vec, + }, + DeleteBlocks { + row: usize, + count: usize, + }, + MoveBlock { + from: usize, + to: usize, + }, + AssetArrived { + id: AssetId, + record: AssetRecord, + }, } diff --git a/crates/gpui-flowtext/src/demo.rs b/crates/gpui-flowtext/src/demo.rs index 730da68e..bd216534 100644 --- a/crates/gpui-flowtext/src/demo.rs +++ b/crates/gpui-flowtext/src/demo.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use crop::Rope; use super::{ - AssetStore, Document, DocumentParagraphInput, DocumentTheme, InputParagraph, InputRun, Paragraph, ParagraphOffsetIndex, ParagraphStyle, - RunStyle, RunStyles, TextRun, document_ids_for_shape, merge_adjacent_runs, paragraph_blocks_from_paragraphs, rebuild_document_sections, - reconcile_document_ids, + AssetStore, DocumentParagraphInput, DocumentProjection, DocumentTheme, InputParagraph, InputRun, Paragraph, ParagraphOffsetIndex, + ParagraphStyle, RunStyle, RunStyles, TextRun, document_ids_for_shape, merge_adjacent_runs, paragraph_blocks_from_paragraphs, + rebuild_document_sections, reconcile_document_ids, }; #[hotpath::measure] #[must_use] -pub fn document_from_input(theme: DocumentTheme, paragraphs: Vec) -> Document { +pub fn document_from_input(theme: DocumentTheme, paragraphs: Vec) -> DocumentProjection { let text_capacity = paragraphs .iter() .map(|paragraph| { @@ -48,7 +48,7 @@ pub fn document_from_input(theme: DocumentTheme, paragraphs: Vec #[hotpath::measure] #[must_use] -pub fn document_from_paragraphs(theme: DocumentTheme, paragraphs: Vec) -> Document { +pub fn document_from_paragraphs(theme: DocumentTheme, paragraphs: Vec) -> DocumentProjection { let text_capacity = paragraphs .iter() .map(|paragraph| { @@ -85,7 +85,7 @@ pub fn document_from_paragraphs(theme: DocumentTheme, paragraphs: Vec) -> Document { +fn document_from_stored_paragraphs(theme: DocumentTheme, text: String, mut stored_paragraphs: Vec) -> DocumentProjection { if stored_paragraphs.is_empty() { stored_paragraphs.push(Paragraph { style: ParagraphStyle::Normal, @@ -98,7 +98,8 @@ fn document_from_stored_paragraphs(theme: DocumentTheme, text: String, mut store let blocks = paragraph_blocks_from_paragraphs(&stored_paragraphs); let paragraph_count = stored_paragraphs.len(); let block_count = blocks.len(); - let mut document = Document { + let mut document = DocumentProjection { + frontier: Vec::new(), text: Rope::from(text), paragraphs: Arc::new(stored_paragraphs), ids: document_ids_for_shape(paragraph_count, block_count), @@ -115,13 +116,13 @@ fn document_from_stored_paragraphs(theme: DocumentTheme, text: String, mut store #[hotpath::measure] #[must_use] -pub fn blank_document() -> Document { +pub fn blank_document() -> DocumentProjection { document_from_input(DocumentTheme::default(), Vec::new()) } #[hotpath::measure] #[must_use] -pub fn demo_document() -> Document { +pub fn demo_document() -> DocumentProjection { let mut paragraphs = vec![ InputParagraph { style: ParagraphStyle::Custom(0), diff --git a/crates/gpui-flowtext/src/document/blocks.rs b/crates/gpui-flowtext/src/document/blocks.rs index de88397a..3cf67925 100644 --- a/crates/gpui-flowtext/src/document/blocks.rs +++ b/crates/gpui-flowtext/src/document/blocks.rs @@ -24,6 +24,26 @@ pub struct AssetRecord { pub bytes: Arc>, } +pub const IMAGE_LOADING_PLACEHOLDER_WIDTH_PX: f32 = 240.0; +pub const IMAGE_LOADING_PLACEHOLDER_HEIGHT_PX: f32 = 160.0; + +impl AssetRecord { + #[must_use] + pub fn stable_content_hash(bytes: &[u8]) -> u64 { + let digest = blake3::hash(bytes); + u64::from_le_bytes( + digest.as_bytes()[..8] + .try_into() + .expect("BLAKE3 digest always contains at least eight bytes"), + ) + } + + #[must_use] + pub fn is_loading_placeholder(&self) -> bool { + self.bytes.is_empty() + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ImageBlock { pub asset_id: AssetId, diff --git a/crates/gpui-flowtext/src/document/core.rs b/crates/gpui-flowtext/src/document/core.rs index 29034961..b4b5745e 100644 --- a/crates/gpui-flowtext/src/document/core.rs +++ b/crates/gpui-flowtext/src/document/core.rs @@ -34,10 +34,13 @@ pub struct RichClipboardFragment { pub assets: Vec, } -// -- Document and paragraphs --------------------------------------------- +// -- DocumentProjection and paragraphs --------------------------------------------- #[derive(Clone, Debug)] -pub struct Document { +pub struct DocumentProjection { + /// Encoded canonical frontier this disposable projection was built from. + /// Empty for standalone projections that are not backed by a CRDT runtime. + pub frontier: Vec, pub text: Rope, pub paragraphs: Arc>, pub blocks: Arc>, @@ -52,7 +55,7 @@ pub struct Document { } #[hotpath::measure] -pub fn paragraphs_mut(document: &mut Document) -> &mut Vec { +pub fn paragraphs_mut(document: &mut DocumentProjection) -> &mut Vec { Arc::make_mut(&mut document.paragraphs) } @@ -63,7 +66,7 @@ pub fn paragraph_blocks_from_paragraphs(paragraphs: &[Paragraph]) -> Vec #[hotpath::measure] #[must_use] -pub fn block_ix_for_paragraph(document: &Document, target_paragraph_ix: usize) -> Option { +pub fn block_ix_for_paragraph(document: &DocumentProjection, target_paragraph_ix: usize) -> Option { if document.blocks.len() == document.paragraphs.len() && document .blocks @@ -87,7 +90,7 @@ pub fn block_ix_for_paragraph(document: &Document, target_paragraph_ix: usize) - #[hotpath::measure] #[must_use] -pub fn document_position_for_offset(document: &Document, offset: DocumentOffset) -> Option { +pub fn document_position_for_offset(document: &DocumentProjection, offset: DocumentOffset) -> Option { let paragraph = document.paragraphs.get(offset.paragraph)?; if offset.byte > paragraph_text_len(paragraph) { return None; @@ -100,7 +103,7 @@ pub fn document_position_for_offset(document: &Document, offset: DocumentOffset) #[hotpath::measure] #[must_use] -pub fn document_offset_for_position(document: &Document, position: &DocumentPosition) -> Option { +pub fn document_offset_for_position(document: &DocumentProjection, position: &DocumentPosition) -> Option { match position { DocumentPosition::Text { block_ix, byte } => { if document.blocks.len() == document.paragraphs.len() @@ -144,7 +147,7 @@ pub fn document_offset_for_position(document: &Document, position: &DocumentPosi } #[hotpath::measure] -pub fn update_paragraph_block(document: &mut Document, paragraph_ix: usize) { +pub fn update_paragraph_block(document: &mut DocumentProjection, paragraph_ix: usize) { let Some(paragraph) = document.paragraphs.get(paragraph_ix).cloned() else { return; }; @@ -156,7 +159,23 @@ pub fn update_paragraph_block(document: &mut Document, paragraph_ix: usize) { } #[hotpath::measure] -pub fn replace_paragraph_blocks(document: &mut Document, start_paragraph: usize, old_count: usize, replacements: &[Paragraph]) { +pub fn replace_paragraph_blocks(document: &mut DocumentProjection, start_paragraph: usize, old_count: usize, replacements: &[Paragraph]) { + // Fast path: a single in-place paragraph update in a paragraph-only-aligned + // document. Block ids and order are unchanged, so we replace just that one + // block instead of rebuilding the whole block vector. + if old_count == 1 + && replacements.len() == 1 + && document.blocks.len() == document.paragraphs.len() + && matches!(document.blocks.get(start_paragraph), Some(Block::Paragraph(_))) + { + if let Some(block) = Arc::make_mut(&mut document.blocks).get_mut(start_paragraph) { + *block = Block::Paragraph(replacements[0].clone()); + } + reconcile_document_ids(document); + rebuild_document_sections(document); + return; + } + let block_start = block_ix_for_paragraph(document, start_paragraph).unwrap_or(document.blocks.len()); let mut paragraph_ix = 0; let mut output = Vec::with_capacity(document.blocks.len() + replacements.len()); @@ -253,7 +272,7 @@ pub fn document_ids_for_shape(paragraph_count: usize, block_count: usize) -> Doc } #[hotpath::measure] -pub fn reconcile_document_ids(document: &mut Document) { +pub fn reconcile_document_ids(document: &mut DocumentProjection) { if document.ids.document_id == 0 { document.ids.document_id = new_document_id(); } @@ -274,7 +293,7 @@ pub fn reconcile_document_ids(document: &mut Document) { #[hotpath::measure] #[must_use] -pub fn paragraph_index_for_id(document: &Document, id: ParagraphId) -> Option { +pub fn paragraph_index_for_id(document: &DocumentProjection, id: ParagraphId) -> Option { document .ids .paragraph_ids @@ -284,18 +303,18 @@ pub fn paragraph_index_for_id(document: &Document, id: ParagraphId) -> Option Option { +pub fn paragraph_id_at(document: &DocumentProjection, paragraph_ix: usize) -> Option { document.ids.paragraph_ids.get(paragraph_ix).copied() } #[hotpath::measure] #[must_use] -pub fn block_id_at(document: &Document, block_ix: usize) -> Option { +pub fn block_id_at(document: &DocumentProjection, block_ix: usize) -> Option { document.ids.block_ids.get(block_ix).copied() } #[hotpath::measure] -pub fn insert_paragraph_id(document: &mut Document, paragraph_ix: usize) -> ParagraphId { +pub fn insert_paragraph_id(document: &mut DocumentProjection, paragraph_ix: usize) -> ParagraphId { let id = new_paragraph_id(); document .ids @@ -305,7 +324,7 @@ pub fn insert_paragraph_id(document: &mut Document, paragraph_ix: usize) -> Para } #[hotpath::measure] -pub fn insert_block_id(document: &mut Document, block_ix: usize) -> BlockId { +pub fn insert_block_id(document: &mut DocumentProjection, block_ix: usize) -> BlockId { let id = new_block_id(); document .ids @@ -315,7 +334,7 @@ pub fn insert_block_id(document: &mut Document, block_ix: usize) -> BlockId { } #[hotpath::measure] -pub fn remove_paragraph_ids(document: &mut Document, range: Range) { +pub fn remove_paragraph_ids(document: &mut DocumentProjection, range: Range) { let start = range.start.min(document.ids.paragraph_ids.len()); let end = range.end.min(document.ids.paragraph_ids.len()); if start < end { @@ -324,7 +343,7 @@ pub fn remove_paragraph_ids(document: &mut Document, range: Range) { } #[hotpath::measure] -pub fn remove_block_ids(document: &mut Document, range: Range) { +pub fn remove_block_ids(document: &mut DocumentProjection, range: Range) { let start = range.start.min(document.ids.block_ids.len()); let end = range.end.min(document.ids.block_ids.len()); if start < end { @@ -333,8 +352,18 @@ pub fn remove_block_ids(document: &mut Document, range: Range) { } #[hotpath::measure] -pub fn rebuild_document_sections(document: &mut Document) { +pub fn rebuild_document_sections(document: &mut DocumentProjection) { reconcile_document_ids(document); + document.sections = Arc::new(document_sections(document)); +} + +/// Computes the heading outline purely from paragraph styles, order, and ids. +/// Pure (no `reconcile_document_ids`); callers that need id reconciliation use +/// [`rebuild_document_sections`]. Because the outline never depends on paragraph +/// text/runs, content-only edits can skip recomputation entirely. +#[hotpath::measure] +#[must_use] +pub fn document_sections(document: &DocumentProjection) -> Vec { let mut sections: Vec = Vec::new(); let mut stack: Vec<(usize, SectionId)> = Vec::new(); @@ -365,6 +394,9 @@ pub fn rebuild_document_sections(document: &mut Document) { heading_paragraph: Some(paragraph_id), start_paragraph: paragraph_id, end_paragraph_exclusive: None, + // §11 page attrs are canonical in Loro; the heading outline cannot derive + // them, so the flowstate-document projector backfills this field. + page: None, }); stack.push((level, id)); } @@ -374,11 +406,30 @@ pub fn rebuild_document_sections(document: &mut Document) { section.end_paragraph_exclusive = None; } } - document.sections = Arc::new(sections); + sections +} + +/// Whether the paragraph at `paragraph_ix` carries a heading (section) style. +#[hotpath::measure] +#[must_use] +pub fn paragraph_is_heading(document: &DocumentProjection, paragraph_ix: usize) -> bool { + document + .paragraphs + .get(paragraph_ix) + .is_some_and(|paragraph| section_level_and_kind(document, paragraph.style).is_some()) +} + +/// Whether any paragraph in `range` (clamped to the paragraph count) is a heading. +/// Lets callers decide whether a content edit can skip [`rebuild_document_sections`]. +#[hotpath::measure] +#[must_use] +pub fn range_contains_heading(document: &DocumentProjection, range: Range) -> bool { + let end = range.end.min(document.paragraphs.len()); + (range.start..end).any(|paragraph_ix| paragraph_is_heading(document, paragraph_ix)) } #[hotpath::measure] -fn section_level_and_kind(document: &Document, style: ParagraphStyle) -> Option<(usize, SectionKind)> { +fn section_level_and_kind(document: &DocumentProjection, style: ParagraphStyle) -> Option<(usize, SectionKind)> { match style { ParagraphStyle::Normal => None, ParagraphStyle::Custom(slot) => { diff --git a/crates/gpui-flowtext/src/document/position.rs b/crates/gpui-flowtext/src/document/position.rs index d1b41e3a..e9937914 100644 --- a/crates/gpui-flowtext/src/document/position.rs +++ b/crates/gpui-flowtext/src/document/position.rs @@ -12,6 +12,65 @@ pub enum ObjectAffinity { After, } +/// §16 selection affinity: which side of a position a selection endpoint +/// "sticks" to. +/// +/// This is the genuine, stored intent behind a caret/endpoint — it is **not** +/// re-derived from selection direction. It records *why* a side was chosen so +/// the caret behaves correctly across concurrent inserts at the same position, +/// undo/redo restoration, object/paragraph boundaries, and selection extension: +/// +/// * [`SelectionAffinity::Before`] — the endpoint belongs to the content +/// *before* the offset (e.g. the caret arrived by moving Left, or sits at the +/// trailing edge of the preceding glyph). Stable cursors anchor to that +/// preceding glyph when one exists. +/// * [`SelectionAffinity::After`] — the endpoint belongs to the content *after* +/// the offset (e.g. the caret arrived by moving Right, or sits at the leading +/// edge of the following glyph). Stable cursors anchor to that following glyph +/// when one exists. +/// * [`SelectionAffinity::Neutral`] — no strong side was expressed (fresh edit +/// result, mouse placement, programmatic selection). Maps to a middle cursor +/// side. +/// +/// The collaboration runtime maps these onto Loro stable cursor boundaries when +/// encoding presence/undo cursors instead of guessing from selection direction. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum SelectionAffinity { + Before, + After, + #[default] + Neutral, +} + +/// §16 visual gravity: which visual line a soft-wrap-seam offset renders on. +/// +/// At a soft wrap, one byte offset is shared by the end of the upper visual line +/// and the start of the lower visual line. Affinity alone cannot disambiguate +/// this purely visual choice, so gravity is tracked separately and **consumed** +/// by caret layout (see `caret_bounds`/`locate_line`): +/// +/// * [`VisualGravity::Upstream`] — render at the trailing edge of the visual +/// line that *ends* at the offset (end of the upper line). Produced by +/// "move/extend to line end". +/// * [`VisualGravity::Downstream`] — render at the leading edge of the visual +/// line that *starts* at the offset (start of the lower line). Produced by +/// "move/extend to line start". +/// * [`VisualGravity::Neutral`] — editor default. Preserves the historical +/// "caret-at-start-of-next-line" wrap-seam bias (i.e. behaves like +/// `Downstream` at a seam), used for arrow/word/vertical motion, fresh edits, +/// mouse placement, and remote carets. +/// +/// Bidi/RTL reordering is **not** modeled here; gravity currently disambiguates +/// only soft-wrap seams in left-to-right text. RTL caret placement remains a +/// defined-but-simplified behavior (treated as `Neutral`). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum VisualGravity { + Upstream, + Downstream, + #[default] + Neutral, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum DocumentPosition { Text { diff --git a/crates/gpui-flowtext/src/document/text.rs b/crates/gpui-flowtext/src/document/text.rs index e56f08d6..7f2ccb72 100644 --- a/crates/gpui-flowtext/src/document/text.rs +++ b/crates/gpui-flowtext/src/document/text.rs @@ -27,7 +27,7 @@ pub enum SectionKind { Custom(u8), } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DocumentSection { pub id: SectionId, pub parent_id: Option, @@ -35,6 +35,130 @@ pub struct DocumentSection { pub heading_paragraph: Option, pub start_paragraph: ParagraphId, pub end_paragraph_exclusive: Option, + /// §11 page-structure payload for this section, when known. + /// + /// The heading-outline computation ([`document_sections`]) cannot derive page + /// structure from paragraph styles, so it leaves this `None`. The canonical + /// values live in Loro and are populated by the `flowstate-document` + /// projector. `#[serde(default)]` keeps older cached projections (which had no + /// such field) deserializable. + #[serde(default)] + pub page: Option, +} + +/// US Letter width in twips (8.5in x 1440). Mirrors the `flowstate-document` +/// Loro encoding so projection mapping is a trivial field-by-field copy (§11). +const DEFAULT_PAGE_WIDTH_TWIPS: i64 = 12_240; +/// US Letter height in twips (11in x 1440). +const DEFAULT_PAGE_HEIGHT_TWIPS: i64 = 15_840; +/// One-inch margin in twips. +const DEFAULT_MARGIN_TWIPS: i64 = 1_440; + +/// §11 page-structure attributes carried by a [`DocumentSection`]. +/// +/// This is the gpui-flowtext-native projection mirror of +/// `flowstate_document::loro_schema::SectionPageAttrs`. gpui-flowtext must not +/// depend on `flowstate-document` (that crate depends on this one), so the type +/// is defined here and the projector maps Loro values onto it field-for-field. +/// All units/semantics match the canonical Loro encoding: lengths are in twips +/// (1/1440 inch), `columns` is a count, and header/footer flow ids reference +/// independent text flows. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SectionPageAttrs { + pub page_size: SectionPageSize, + pub margins: SectionMargins, + pub columns: i64, + pub orientation: SectionOrientation, + pub page_numbering: SectionPageNumbering, + pub header_flow_id: Option, + pub footer_flow_id: Option, +} + +impl Default for SectionPageAttrs { + fn default() -> Self { + Self { + page_size: SectionPageSize::default(), + margins: SectionMargins::default(), + columns: 1, + orientation: SectionOrientation::Portrait, + page_numbering: SectionPageNumbering::default(), + header_flow_id: None, + footer_flow_id: None, + } + } +} + +/// §11 page size in twips. Defaults to US Letter. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SectionPageSize { + pub width_twips: i64, + pub height_twips: i64, +} + +impl Default for SectionPageSize { + fn default() -> Self { + Self { + width_twips: DEFAULT_PAGE_WIDTH_TWIPS, + height_twips: DEFAULT_PAGE_HEIGHT_TWIPS, + } + } +} + +/// §11 section margins in twips. Defaults to one-inch margins. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SectionMargins { + pub top_twips: i64, + pub right_twips: i64, + pub bottom_twips: i64, + pub left_twips: i64, +} + +impl Default for SectionMargins { + fn default() -> Self { + Self { + top_twips: DEFAULT_MARGIN_TWIPS, + right_twips: DEFAULT_MARGIN_TWIPS, + bottom_twips: DEFAULT_MARGIN_TWIPS, + left_twips: DEFAULT_MARGIN_TWIPS, + } + } +} + +/// §11 section page orientation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SectionOrientation { + #[default] + Portrait, + Landscape, +} + +/// §11 page-numbering descriptor for a section. Defaults to no numbering +/// starting at 1. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SectionPageNumbering { + pub format: PageNumberFormat, + pub start: i64, +} + +impl Default for SectionPageNumbering { + fn default() -> Self { + Self { + format: PageNumberFormat::None, + start: 1, + } + } +} + +/// §11 page-number rendering format for a section. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PageNumberFormat { + #[default] + None, + Decimal, + LowerRoman, + UpperRoman, + LowerAlpha, + UpperAlpha, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] @@ -81,13 +205,13 @@ pub struct DocumentSpan { /// Input-shape used by document builders (demo data, clipboard fragments). /// Carries explicit run text instead of byte offsets so the higher-level /// helpers can splice in arbitrary content. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputRun { pub text: String, pub styles: RunStyles, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputParagraph { pub style: ParagraphStyle, pub runs: Vec, @@ -102,7 +226,7 @@ pub struct InputAsset { pub bytes: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputBlock { Paragraph(InputParagraph), Image(InputImageBlock), @@ -110,7 +234,7 @@ pub enum InputBlock { Table(InputTableBlock), } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputImageBlock { pub asset_id: AssetId, pub alt_text: String, @@ -119,71 +243,71 @@ pub struct InputImageBlock { pub alignment: InputBlockAlignment, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputImageSizing { Intrinsic, FitWidth, Fixed { width_px: u32, height_px: Option }, } -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputBlockAlignment { Left, Center, Right, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputEquationBlock { pub source: String, pub syntax: InputEquationSyntax, pub display: InputEquationDisplay, } -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputEquationSyntax { Latex, } -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputEquationDisplay { Display, InlineLikeParagraph, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputTableBlock { pub rows: Vec, pub column_widths: Vec, pub style: InputTableStyle, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputTableRow { pub cells: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputTableCell { pub blocks: Vec, pub row_span: u16, pub col_span: u16, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputTableCellBlock { Paragraph(InputParagraph), Table(InputTableBlock), } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum InputTableColumnWidth { Auto, FixedPx(u32), Fraction(u32), } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct InputTableStyle { pub header_row: bool, } diff --git a/crates/gpui-flowtext/src/document/theme.rs b/crates/gpui-flowtext/src/document/theme.rs index 820509e4..695ce061 100644 --- a/crates/gpui-flowtext/src/document/theme.rs +++ b/crates/gpui-flowtext/src/document/theme.rs @@ -181,4 +181,4 @@ impl DocumentTheme { } } -// -- Document offset ------------------------------------------------------ +// -- DocumentProjection offset ------------------------------------------------------ diff --git a/crates/gpui-flowtext/src/edit_ops/fragments.rs b/crates/gpui-flowtext/src/edit_ops/fragments.rs index e732820a..27eec49f 100644 --- a/crates/gpui-flowtext/src/edit_ops/fragments.rs +++ b/crates/gpui-flowtext/src/edit_ops/fragments.rs @@ -1,6 +1,6 @@ #[hotpath::measure] #[must_use] -pub fn selected_rich_fragment(document: &Document, range: Range) -> RichClipboardFragment { +pub fn selected_rich_fragment(document: &DocumentProjection, range: Range) -> RichClipboardFragment { if document.paragraphs.is_empty() || range.start.paragraph > range.end.paragraph { return RichClipboardFragment { format: RICH_TEXT_CLIPBOARD_FORMAT.to_owned(), diff --git a/crates/gpui-flowtext/src/edit_ops/insert_delete.rs b/crates/gpui-flowtext/src/edit_ops/insert_delete.rs index efb68bad..198f32a9 100644 --- a/crates/gpui-flowtext/src/edit_ops/insert_delete.rs +++ b/crates/gpui-flowtext/src/edit_ops/insert_delete.rs @@ -1,8 +1,9 @@ #[hotpath::measure] -pub fn insert_text_at(document: &mut Document, paragraph_ix: usize, byte: usize, text: &str, styles: RunStyles) { +pub fn insert_text_at(document: &mut DocumentProjection, paragraph_ix: usize, byte: usize, text: &str, styles: RunStyles) { if text.is_empty() { return; } + let byte = clamp_paragraph_byte_to_char_boundary(document, paragraph_ix, byte); let insert_len = text.len(); let paragraph_start = paragraph_byte_range(document, paragraph_ix).start; document.text.insert(paragraph_start + byte, text); @@ -88,10 +89,13 @@ pub fn insert_text_at(document: &mut Document, paragraph_ix: usize, byte: usize, // `paragraph`. Runs are split or dropped as needed; remaining runs are re- // merged so adjacent same-style fragments coalesce. #[hotpath::measure] -pub fn delete_range_in_paragraph(document: &mut Document, paragraph_ix: usize, range: Range) { - if range.start >= range.end { +pub fn delete_range_in_paragraph(document: &mut DocumentProjection, paragraph_ix: usize, range: Range) { + let start = clamp_paragraph_byte_to_char_boundary(document, paragraph_ix, range.start); + let end = clamp_paragraph_byte_to_char_boundary(document, paragraph_ix, range.end); + if start >= end { return; } + let range = start..end; let paragraph_start = paragraph_byte_range(document, paragraph_ix).start; document .text diff --git a/crates/gpui-flowtext/src/edit_ops/mutate.rs b/crates/gpui-flowtext/src/edit_ops/mutate.rs index a71d832f..11fb6ec6 100644 --- a/crates/gpui-flowtext/src/edit_ops/mutate.rs +++ b/crates/gpui-flowtext/src/edit_ops/mutate.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -pub fn mutate_runs_in_range(document: &mut Document, range: Range, mut mutate: impl FnMut(&mut RunStyles)) { +pub fn mutate_runs_in_range(document: &mut DocumentProjection, range: Range, mut mutate: impl FnMut(&mut RunStyles)) { let paragraph_count = paragraphs_mut(document).len(); if paragraph_count == 0 || range.start.paragraph >= paragraph_count || range.start.paragraph > range.end.paragraph { return; diff --git a/crates/gpui-flowtext/src/edit_ops/offsets.rs b/crates/gpui-flowtext/src/edit_ops/offsets.rs index f930d8b7..6d24ac8e 100644 --- a/crates/gpui-flowtext/src/edit_ops/offsets.rs +++ b/crates/gpui-flowtext/src/edit_ops/offsets.rs @@ -24,55 +24,99 @@ pub fn paragraph_width(paragraphs: &[Paragraph], paragraph_ix: usize) -> Option< #[hotpath::measure] #[must_use] -pub fn paragraph_byte_range(document: &Document, paragraph_ix: usize) -> Range { +pub fn paragraph_byte_range(document: &DocumentProjection, paragraph_ix: usize) -> Range { let start = document.offset_index.paragraph_start(paragraph_ix); start..start + paragraph_text_len(&document.paragraphs[paragraph_ix]) } +/// Clamp a paragraph-local UTF-8 byte offset to the nearest valid character +/// boundary at or before it. +/// +/// Editor coordinates are byte-based. Keeping this normalization at the +/// document boundary prevents a stale or transformed caret from ever reaching +/// `crop::Rope` with an interior UTF-8 code-unit offset. #[hotpath::measure] -pub fn refresh_paragraph_range(document: &mut Document, paragraph_ix: usize) { - let range = paragraph_byte_range(document, paragraph_ix); - paragraphs_mut(document)[paragraph_ix].byte_range = range; +#[must_use] +pub fn clamp_paragraph_byte_to_char_boundary(document: &DocumentProjection, paragraph_ix: usize, byte: usize) -> usize { + let Some(paragraph) = document.paragraphs.get(paragraph_ix) else { + return 0; + }; + let paragraph_start = document.offset_index.paragraph_start(paragraph_ix); + let mut byte = byte.min(paragraph_text_len(paragraph)); + while byte > 0 && !document.text.is_char_boundary(paragraph_start + byte) { + byte -= 1; + } + byte } #[hotpath::measure] -pub fn refresh_paragraph_ranges(document: &mut Document) { - for paragraph_ix in 0..document.paragraphs.len() { - refresh_paragraph_range(document, paragraph_ix); - } +pub fn refresh_paragraph_range(document: &mut DocumentProjection, paragraph_ix: usize) { + let range = paragraph_byte_range(document, paragraph_ix); + paragraphs_mut(document)[paragraph_ix].byte_range = range; } #[hotpath::measure] -fn refresh_paragraph_ranges_from(document: &mut Document, start_paragraph: usize) { - let paragraph_count = document.paragraphs.len(); - for paragraph_ix in start_paragraph.min(paragraph_count)..paragraph_count { +pub fn refresh_paragraph_ranges(document: &mut DocumentProjection) { + for paragraph_ix in 0..document.paragraphs.len() { refresh_paragraph_range(document, paragraph_ix); } } #[hotpath::measure] -pub fn rebuild_document_offset_index(document: &mut Document) { +pub fn rebuild_document_offset_index(document: &mut DocumentProjection) { document.offset_index.rebuild(&document.paragraphs); refresh_paragraph_ranges(document); } +fn shift_byte_range(range: &Range, delta: isize) -> Range { + range.start.saturating_add_signed(delta)..range.end.saturating_add_signed(delta) +} + +// Per-keystroke hot path: a single paragraph's text length changed (paragraph +// COUNT is unchanged). Update the Fenwick offset index in O(log n), shift the +// cached `byte_range`s of the edited paragraph and the ones after it, and mirror +// the change into the parallel block representation in place. We deliberately do +// NOT clone the paragraph tail, rebuild the block vector, reconcile ids, or +// rebuild the section outline: a content-only edit changes none of those. #[hotpath::measure] -pub fn update_paragraph_offsets_after_len_change(document: &mut Document, paragraph_ix: usize) { +pub fn update_paragraph_offsets_after_len_change(document: &mut DocumentProjection, paragraph_ix: usize) { if paragraph_ix >= document.paragraphs.len() { return; } + let new_len = paragraph_text_len(&document.paragraphs[paragraph_ix]); + let old_len = document.paragraphs[paragraph_ix].byte_range.len(); + let delta = new_len as isize - old_len as isize; document .offset_index .update_paragraph_width(paragraph_ix, &document.paragraphs); - refresh_paragraph_ranges_from(document, paragraph_ix); - let paragraph_count = document.paragraphs.len(); - let replacements = document.paragraphs[paragraph_ix..].to_vec(); - replace_paragraph_blocks( - document, - paragraph_ix, - paragraph_count.saturating_sub(paragraph_ix), - &replacements, - ); + + let start = document.paragraphs[paragraph_ix].byte_range.start; + { + let paragraphs = paragraphs_mut(document); + paragraphs[paragraph_ix].byte_range = start..start + new_len; + if delta != 0 { + for paragraph in paragraphs.iter_mut().skip(paragraph_ix + 1) { + paragraph.byte_range = shift_byte_range(¶graph.byte_range, delta); + } + } + } + + let mut edited = Some(document.paragraphs[paragraph_ix].clone()); + let blocks = Arc::make_mut(&mut document.blocks); + let mut paragraph_ord = 0usize; + for block in blocks.iter_mut() { + let Block::Paragraph(paragraph) = block else { + continue; + }; + if paragraph_ord == paragraph_ix { + if let Some(updated) = edited.take() { + *paragraph = updated; + } + } else if paragraph_ord > paragraph_ix && delta != 0 { + paragraph.byte_range = shift_byte_range(¶graph.byte_range, delta); + } + paragraph_ord += 1; + } } // Returns `(run_index, local_byte)` for the given absolute byte offset within diff --git a/crates/gpui-flowtext/src/edit_ops/rich_fragment.rs b/crates/gpui-flowtext/src/edit_ops/rich_fragment.rs index 287f72a1..06d6be6f 100644 --- a/crates/gpui-flowtext/src/edit_ops/rich_fragment.rs +++ b/crates/gpui-flowtext/src/edit_ops/rich_fragment.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -pub fn insert_rich_fragment_at(document: &mut Document, offset: DocumentOffset, fragment: &RichClipboardFragment) -> DocumentOffset { +pub fn insert_rich_fragment_at(document: &mut DocumentProjection, offset: DocumentOffset, fragment: &RichClipboardFragment) -> DocumentOffset { let Some(first_paragraph) = fragment.paragraphs.first() else { return offset; }; @@ -10,7 +10,7 @@ pub fn insert_rich_fragment_at(document: &mut Document, offset: DocumentOffset, } #[hotpath::measure] -fn insert_single_paragraph_fragment_at(document: &mut Document, offset: DocumentOffset, paragraph: &InputParagraph) -> DocumentOffset { +fn insert_single_paragraph_fragment_at(document: &mut DocumentProjection, offset: DocumentOffset, paragraph: &InputParagraph) -> DocumentOffset { let text = input_paragraph_text(paragraph); if text.is_empty() { return offset; @@ -18,7 +18,7 @@ fn insert_single_paragraph_fragment_at(document: &mut Document, offset: Document let Some(target) = document.paragraphs.get(offset.paragraph) else { return offset; }; - let byte = offset.byte.min(paragraph_text_len(target)); + let byte = clamp_paragraph_byte_to_char_boundary(document, offset.paragraph, offset.byte); let paragraph_start = paragraph_byte_range(document, offset.paragraph).start; let (left_runs, right_runs) = split_runs_at(&target.runs, byte); document.text.insert(paragraph_start + byte, &text); @@ -41,11 +41,11 @@ fn insert_single_paragraph_fragment_at(document: &mut Document, offset: Document } #[hotpath::measure] -fn insert_multi_paragraph_fragment_at(document: &mut Document, offset: DocumentOffset, fragment: &RichClipboardFragment) -> DocumentOffset { +fn insert_multi_paragraph_fragment_at(document: &mut DocumentProjection, offset: DocumentOffset, fragment: &RichClipboardFragment) -> DocumentOffset { let Some(target) = document.paragraphs.get(offset.paragraph).cloned() else { return offset; }; - let byte = offset.byte.min(paragraph_text_len(&target)); + let byte = clamp_paragraph_byte_to_char_boundary(document, offset.paragraph, offset.byte); let paragraph_start = paragraph_byte_range(document, offset.paragraph).start; let (left_runs, right_runs) = split_runs_at(&target.runs, byte); let inserted_text = fragment diff --git a/crates/gpui-flowtext/src/edit_ops/selection_styles.rs b/crates/gpui-flowtext/src/edit_ops/selection_styles.rs index 1ba0b21b..3681a0a1 100644 --- a/crates/gpui-flowtext/src/edit_ops/selection_styles.rs +++ b/crates/gpui-flowtext/src/edit_ops/selection_styles.rs @@ -1,14 +1,23 @@ #[hotpath::measure] #[must_use] -pub fn selection_run_styles(document: &Document, range: Range) -> Vec { +pub fn selection_run_styles(document: &DocumentProjection, range: Range) -> Vec { let mut styles = Vec::new(); - for paragraph_ix in range.start.paragraph..=range.end.paragraph { + if document.paragraphs.is_empty() || range.start.paragraph >= document.paragraphs.len() { + return styles; + } + let last_paragraph = range.end.paragraph.min(document.paragraphs.len() - 1); + for paragraph_ix in range.start.paragraph..=last_paragraph { let paragraph = &document.paragraphs[paragraph_ix]; - let start = if paragraph_ix == range.start.paragraph { range.start.byte } else { 0 }; + let paragraph_len = paragraph_text_len(paragraph); + let start = if paragraph_ix == range.start.paragraph { + range.start.byte.min(paragraph_len) + } else { + 0 + }; let end = if paragraph_ix == range.end.paragraph { - range.end.byte + range.end.byte.min(paragraph_len) } else { - paragraph_text_len(paragraph) + paragraph_len }; let mut offset = 0; for run in ¶graph.runs { @@ -25,20 +34,24 @@ pub fn selection_run_styles(document: &Document, range: Range) - #[hotpath::measure] #[must_use] -pub fn selection_prefers_direct_underline(document: &Document, range: Range) -> bool { - (range.start.paragraph..=range.end.paragraph) +pub fn selection_prefers_direct_underline(document: &DocumentProjection, range: Range) -> bool { + if document.paragraphs.is_empty() || range.start.paragraph >= document.paragraphs.len() { + return false; + } + let last_paragraph = range.end.paragraph.min(document.paragraphs.len() - 1); + (range.start.paragraph..=last_paragraph) .any(|paragraph_ix| matches!(document.paragraphs[paragraph_ix].style, ParagraphStyle::Custom(3) | ParagraphStyle::Custom(4))) } #[hotpath::measure] -pub fn selection_all_run_styles(document: &Document, range: Range, predicate: impl Fn(RunStyles) -> bool) -> bool { +pub fn selection_all_run_styles(document: &DocumentProjection, range: Range, predicate: impl Fn(RunStyles) -> bool) -> bool { let styles = selection_run_styles(document, range); !styles.is_empty() && styles.into_iter().all(predicate) } #[hotpath::measure] #[must_use] -pub fn selection_all_underline_kind(document: &Document, range: Range, direct: bool) -> bool { +pub fn selection_all_underline_kind(document: &DocumentProjection, range: Range, direct: bool) -> bool { selection_all_run_styles(document, range, |styles| { if direct { styles.direct_underline @@ -50,20 +63,29 @@ pub fn selection_all_underline_kind(document: &Document, range: Range) -> bool { - (range.start.paragraph..=range.end.paragraph).any(|paragraph_ix| { - let start = if paragraph_ix == range.start.paragraph { range.start.byte } else { 0 }; +pub fn selection_contains_whole_paragraph(document: &DocumentProjection, range: Range) -> bool { + if document.paragraphs.is_empty() || range.start.paragraph >= document.paragraphs.len() { + return false; + } + let last_paragraph = range.end.paragraph.min(document.paragraphs.len() - 1); + (range.start.paragraph..=last_paragraph).any(|paragraph_ix| { + let paragraph_len = paragraph_text_len(&document.paragraphs[paragraph_ix]); + let start = if paragraph_ix == range.start.paragraph { + range.start.byte.min(paragraph_len) + } else { + 0 + }; let end = if paragraph_ix == range.end.paragraph { - range.end.byte + range.end.byte.min(paragraph_len) } else { - paragraph_text_len(&document.paragraphs[paragraph_ix]) + paragraph_len }; - start == 0 && end == paragraph_text_len(&document.paragraphs[paragraph_ix]) + start == 0 && end == paragraph_len }) } #[hotpath::measure] -pub fn clear_whole_paragraph_formatting(document: &mut Document, paragraph_ix: usize) { +pub fn clear_whole_paragraph_formatting(document: &mut DocumentProjection, paragraph_ix: usize) { let Some(paragraph) = paragraphs_mut(document).get_mut(paragraph_ix) else { return; }; diff --git a/crates/gpui-flowtext/src/edit_ops/split_delete.rs b/crates/gpui-flowtext/src/edit_ops/split_delete.rs index 57314c4f..f0fa0353 100644 --- a/crates/gpui-flowtext/src/edit_ops/split_delete.rs +++ b/crates/gpui-flowtext/src/edit_ops/split_delete.rs @@ -37,7 +37,8 @@ pub fn split_runs_at(runs: &[TextRun], byte: usize) -> (Vec, Vec) { +pub fn delete_cross_paragraph_range(document: &mut DocumentProjection, range: Range) { if range.start.paragraph >= range.end.paragraph { delete_range_in_paragraph(document, range.start.paragraph, range.start.byte..range.end.byte); return; @@ -77,16 +78,18 @@ pub fn delete_cross_paragraph_range(document: &mut Document, range: Range, style: RunStyle) { +pub fn apply_style_to_paragraph_range(document: &mut DocumentProjection, paragraph_ix: usize, range: Range, style: RunStyle) { if range.start >= range.end { return; } diff --git a/crates/gpui-flowtext/src/edit_ops/text.rs b/crates/gpui-flowtext/src/edit_ops/text.rs index e87f622a..3b805c07 100644 --- a/crates/gpui-flowtext/src/edit_ops/text.rs +++ b/crates/gpui-flowtext/src/edit_ops/text.rs @@ -1,6 +1,6 @@ #[hotpath::measure] #[must_use] -pub fn paragraph_text(document: &Document, paragraph_ix: usize) -> String { +pub fn paragraph_text(document: &DocumentProjection, paragraph_ix: usize) -> String { document_text_slice(document, paragraph_byte_range(document, paragraph_ix)) } @@ -12,7 +12,7 @@ pub fn paragraph_text_len(paragraph: &Paragraph) -> usize { #[hotpath::measure] #[must_use] -pub fn document_text_slice(document: &Document, range: Range) -> String { +pub fn document_text_slice(document: &DocumentProjection, range: Range) -> String { let len = document.text.byte_len(); let start = range.start.min(len); let end = range.end.min(len); @@ -25,7 +25,7 @@ pub fn document_text_slice(document: &Document, range: Range) -> String { } #[hotpath::measure] -pub fn push_document_text_slice(document: &Document, range: Range, text: &mut String) { +pub fn push_document_text_slice(document: &DocumentProjection, range: Range, text: &mut String) { let len = document.text.byte_len(); let start = range.start.min(len); let end = range.end.min(len); @@ -39,13 +39,13 @@ pub fn push_document_text_slice(document: &Document, range: Range, text: #[hotpath::measure] #[must_use] -pub fn paragraph_char_count(document: &Document, paragraph_ix: usize, needle: char) -> usize { +pub fn paragraph_char_count(document: &DocumentProjection, paragraph_ix: usize, needle: char) -> usize { document_text_slice_char_count(document, paragraph_byte_range(document, paragraph_ix), needle) } #[hotpath::measure] #[must_use] -pub fn document_text_slice_char_count(document: &Document, range: Range, needle: char) -> usize { +pub fn document_text_slice_char_count(document: &DocumentProjection, range: Range, needle: char) -> usize { document .text .byte_slice(range) @@ -56,7 +56,7 @@ pub fn document_text_slice_char_count(document: &Document, range: Range, #[hotpath::measure] #[must_use] -pub fn capture_document_span(document: &Document, range: Range) -> DocumentSpan { +pub fn capture_document_span(document: &DocumentProjection, range: Range) -> DocumentSpan { let start = range.start.min(document.paragraphs.len()); let end = range.end.min(document.paragraphs.len()).max(start); let text = if start < end { @@ -73,7 +73,7 @@ pub fn capture_document_span(document: &Document, range: Range) -> Docume } #[hotpath::measure] -pub fn apply_document_span_replacement(document: &mut Document, current: &DocumentSpan, replacement: &DocumentSpan) { +pub fn apply_document_span_replacement(document: &mut DocumentProjection, current: &DocumentSpan, replacement: &DocumentSpan) { let byte_range = paragraph_span_byte_range(document, current.start_paragraph, current.paragraphs.len()); document.text.delete(byte_range.clone()); document.text.insert(byte_range.start, &replacement.text); @@ -92,13 +92,14 @@ pub fn apply_document_span_replacement(document: &mut Document, current: &Docume paragraph_end.saturating_sub(current.start_paragraph), &replacement.paragraphs, ); + // `replace_paragraph_blocks` already rebuilt the section outline; only the + // byte-offset index still needs refreshing after the splice. rebuild_document_offset_index(document); - rebuild_document_sections(document); } #[hotpath::measure] #[must_use] -pub fn paragraph_span_byte_range(document: &Document, start_paragraph: usize, paragraph_count: usize) -> Range { +pub fn paragraph_span_byte_range(document: &DocumentProjection, start_paragraph: usize, paragraph_count: usize) -> Range { if paragraph_count == 0 || start_paragraph >= document.paragraphs.len() { let byte = document .paragraphs @@ -118,12 +119,12 @@ pub fn paragraph_span_byte_range(document: &Document, start_paragraph: usize, pa )] #[hotpath::measure] #[must_use] -pub fn full_document_text(document: &Document) -> String { +pub fn full_document_text(document: &DocumentProjection) -> String { document_text_slice(document, 0..document.text.byte_len()) } #[hotpath::measure] -pub fn document_end(document: &Document) -> DocumentOffset { +pub fn document_end(document: &DocumentProjection) -> DocumentOffset { let paragraph = document.paragraphs.len().saturating_sub(1); DocumentOffset { paragraph, @@ -140,14 +141,14 @@ pub fn document_end(document: &Document) -> DocumentOffset { )] #[hotpath::measure] #[must_use] -pub fn global_byte(document: &Document, offset: DocumentOffset) -> usize { +pub fn global_byte(document: &DocumentProjection, offset: DocumentOffset) -> usize { paragraph_byte_range(document, offset.paragraph).start + offset.byte } #[allow(dead_code, reason = "Global-to-document offset conversion is retained for file/search integrations.")] #[hotpath::measure] #[must_use] -pub fn global_to_document_offset(document: &Document, byte: usize) -> DocumentOffset { +pub fn global_to_document_offset(document: &DocumentProjection, byte: usize) -> DocumentOffset { let byte = byte.min(document.text.byte_len()); let mut low = 0; let mut high = document.paragraphs.len(); @@ -172,19 +173,19 @@ pub fn global_to_document_offset(document: &Document, byte: usize) -> DocumentOf #[hotpath::measure] #[must_use] -pub fn find_text_ranges(document: &Document, query: &str) -> Vec> { +pub fn find_text_ranges(document: &DocumentProjection, query: &str) -> Vec> { find_text_ranges_with_case(document, query, true) } #[hotpath::measure] #[must_use] -pub fn find_text_ranges_with_case(document: &Document, query: &str, case_sensitive: bool) -> Vec> { +pub fn find_text_ranges_with_case(document: &DocumentProjection, query: &str, case_sensitive: bool) -> Vec> { find_text_ranges_with_options(document, query, case_sensitive, false) } #[hotpath::measure] #[must_use] -pub fn find_text_ranges_with_options(document: &Document, query: &str, case_sensitive: bool, whole_words: bool) -> Vec> { +pub fn find_text_ranges_with_options(document: &DocumentProjection, query: &str, case_sensitive: bool, whole_words: bool) -> Vec> { if query.is_empty() { return Vec::new(); } @@ -233,7 +234,7 @@ fn next_char_is_word_like(text: &str, byte: usize) -> bool { #[hotpath::measure] #[must_use] -pub fn selected_plain_text(document: &Document, range: Range) -> String { +pub fn selected_plain_text(document: &DocumentProjection, range: Range) -> String { if document.paragraphs.is_empty() { return String::new(); } diff --git a/crates/gpui-flowtext/src/lib.rs b/crates/gpui-flowtext/src/lib.rs index ecf63458..34bbc47a 100644 --- a/crates/gpui-flowtext/src/lib.rs +++ b/crates/gpui-flowtext/src/lib.rs @@ -16,7 +16,7 @@ pub use rich_text::*; pub mod prelude { pub use crate::{ - Document, DocumentTheme, EditorSelection, HighlightStyle, Paragraph, ParagraphStyle, RichTextDocumentElement, RichTextEditor, + DocumentProjection, DocumentTheme, EditorSelection, HighlightStyle, Paragraph, ParagraphStyle, RichTextDocumentElement, RichTextEditor, RichTextEditorCommand, RunSemanticStyle, RunStyle, RunStyles, TextRun, }; } @@ -35,17 +35,10 @@ pub mod editor_api { }; } -pub mod persistence_api { - pub use crate::{ - DEFAULT_DOCUMENT_EXTENSION, document_bytes, load_or_create_document, read_document, read_document_bytes, recovery_path_for_document, - write_document, - }; -} - pub mod host { pub use crate::{ - AssetResolver, BlockKindId, DocumentExportAdapter, DocumentExportFormat, DocumentSerializer, ExternalFormatExporter, - set_document_export_adapter, + AssetResolver, BlockKindId, DocumentExportAdapter, DocumentExportFormat, DocumentRecoveryAdapter, DocumentSerializer, + ExternalFormatExporter, set_document_export_adapter, set_document_recovery_adapter, }; } @@ -53,19 +46,3 @@ pub mod advanced { pub use crate::collaboration::*; pub use crate::edit_ops::*; } - -use std::time::Instant; - -const TIMING_ENV: &str = "DEBATEPROCESSOR_TIMING"; - -#[hotpath::measure] -fn timing_enabled() -> bool { - std::env::var_os(TIMING_ENV).is_some() -} - -#[hotpath::measure] -pub(crate) fn log_timing_lazy(label: &str, start: Instant, detail: impl FnOnce() -> String) { - if timing_enabled() { - eprintln!("[timing] {label}: {:?} {}", start.elapsed(), detail()); - } -} diff --git a/crates/gpui-flowtext/src/persistence/codec.rs b/crates/gpui-flowtext/src/persistence/codec.rs deleted file mode 100644 index 25fa1b8c..00000000 --- a/crates/gpui-flowtext/src/persistence/codec.rs +++ /dev/null @@ -1,226 +0,0 @@ -#[hotpath::measure] -fn read_u8(cursor: &mut Cursor<&[u8]>) -> io::Result { - let mut bytes = [0; 1]; - cursor.read_exact(&mut bytes)?; - Ok(bytes[0]) -} - -#[hotpath::measure] -fn read_u16(cursor: &mut Cursor<&[u8]>) -> io::Result { - let mut bytes = [0; 2]; - cursor.read_exact(&mut bytes)?; - Ok(u16::from_le_bytes(bytes)) -} - -#[hotpath::measure] -fn read_u32(cursor: &mut Cursor<&[u8]>) -> io::Result { - let mut bytes = [0; 4]; - cursor.read_exact(&mut bytes)?; - Ok(u32::from_le_bytes(bytes)) -} - -#[hotpath::measure] -fn write_u32(bytes: &mut Vec, value: u32) { - bytes.extend_from_slice(&value.to_le_bytes()); -} - -#[hotpath::measure] -fn read_u64(cursor: &mut Cursor<&[u8]>) -> io::Result { - let mut bytes = [0; 8]; - cursor.read_exact(&mut bytes)?; - Ok(u64::from_le_bytes(bytes)) -} - -#[hotpath::measure] -fn read_u128(cursor: &mut Cursor<&[u8]>) -> io::Result { - let mut bytes = [0; 16]; - cursor.read_exact(&mut bytes)?; - Ok(u128::from_le_bytes(bytes)) -} - -#[hotpath::measure] -fn write_u64(bytes: &mut Vec, value: u64) { - bytes.extend_from_slice(&value.to_le_bytes()); -} - -#[hotpath::measure] -fn write_u128(bytes: &mut Vec, value: u128) { - bytes.extend_from_slice(&value.to_le_bytes()); -} - -#[hotpath::measure] -fn read_len(cursor: &mut Cursor<&[u8]>, label: &'static str) -> io::Result { - let raw = read_u64(cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("{label} overflows usize"))) -} - -#[hotpath::measure] -fn read_bytes<'bytes>(cursor: &mut Cursor<&'bytes [u8]>, len: usize, label: &'static str) -> io::Result<&'bytes [u8]> { - let start = usize::try_from(cursor.position()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("{label} cursor position overflows usize")))?; - let end = start - .checked_add(len) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{label} length overflows usize")))?; - if end > cursor.get_ref().len() { - return Err(io::Error::new(io::ErrorKind::UnexpectedEof, format!("{label} is truncated"))); - } - cursor.set_position(end as u64); - Ok(&cursor.get_ref()[start..end]) -} - -#[hotpath::measure] -fn read_string(cursor: &mut Cursor<&[u8]>) -> io::Result { - let len = read_len(cursor, "native document string length")?; - let bytes = read_bytes(cursor, len, "native document string")?; - std::str::from_utf8(bytes) - .map(std::borrow::ToOwned::to_owned) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document string is not UTF-8")) -} - -#[hotpath::measure] -fn write_string(bytes: &mut Vec, value: &str) { - write_u64(bytes, value.len() as u64); - bytes.extend_from_slice(value.as_bytes()); -} - -const fn encode_block_alignment(alignment: BlockAlignment) -> u8 { - match alignment { - BlockAlignment::Left => 0, - BlockAlignment::Center => 1, - BlockAlignment::Right => 2, - } -} - -#[hotpath::measure] -fn decode_block_alignment(value: u8) -> io::Result { - match value { - 0 => Ok(BlockAlignment::Left), - 1 => Ok(BlockAlignment::Center), - 2 => Ok(BlockAlignment::Right), - _ => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid block alignment")), - } -} - -const fn encode_paragraph_style(style: ParagraphStyle) -> u8 { - match style { - ParagraphStyle::Custom(0) => 0, - ParagraphStyle::Custom(1) => 1, - ParagraphStyle::Custom(2) => 2, - ParagraphStyle::Custom(3) => 3, - ParagraphStyle::Custom(4) => 4, - ParagraphStyle::Normal => 5, - ParagraphStyle::Custom(6) => 6, - ParagraphStyle::Custom(slot) => 128 + (slot & 0x7f), - } -} - -#[hotpath::measure] -fn decode_paragraph_style(value: u8) -> io::Result { - match value { - 0 => Ok(ParagraphStyle::Custom(0)), - 1 => Ok(ParagraphStyle::Custom(1)), - 2 => Ok(ParagraphStyle::Custom(2)), - 3 => Ok(ParagraphStyle::Custom(3)), - 4 => Ok(ParagraphStyle::Custom(4)), - 5 => Ok(ParagraphStyle::Normal), - 6 => Ok(ParagraphStyle::Custom(6)), - 128..=255 => Ok(ParagraphStyle::Custom(value - 128)), - _ => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid paragraph style")), - } -} - -const fn encode_section_kind(kind: SectionKind) -> u8 { - match kind { - SectionKind::Custom(slot) => slot & 0x7f, - } -} - -#[hotpath::measure] -fn decode_section_kind(value: u8) -> io::Result { - match value { - 0..=127 => Ok(SectionKind::Custom(value)), - 128..=255 => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid section kind")), - } -} - -#[hotpath::measure] -fn write_run_styles(bytes: &mut Vec, styles: RunStyles) { - bytes.push(encode_run_semantic_style(styles.semantic)); - let mut flags = 0_u8; - if styles.direct_underline { - flags |= 1 << 0; - } - if styles.strikethrough { - flags |= 1 << 1; - } - bytes.push(flags); - bytes.push(encode_highlight_style(styles.highlight)); -} - -#[hotpath::measure] -fn read_run_styles(cursor: &mut Cursor<&[u8]>) -> io::Result { - let semantic = decode_run_semantic_style(read_u8(cursor)?)?; - let flags = read_u8(cursor)?; - if flags & !0b0000_0011 != 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid run style flags")); - } - Ok(RunStyles { - semantic, - direct_underline: flags & (1 << 0) != 0, - strikethrough: flags & (1 << 1) != 0, - highlight: decode_highlight_style(read_u8(cursor)?)?, - }) -} - -const fn encode_run_semantic_style(style: RunSemanticStyle) -> u8 { - match style { - RunSemanticStyle::Plain => 0, - RunSemanticStyle::Custom(1) => 1, - RunSemanticStyle::Custom(2) => 2, - RunSemanticStyle::Custom(3) => 3, - RunSemanticStyle::Custom(4) => 4, - RunSemanticStyle::Custom(5) => 5, - RunSemanticStyle::Custom(slot) => 128 + (slot & 0x7f), - } -} - -#[hotpath::measure] -fn decode_run_semantic_style(value: u8) -> io::Result { - match value { - 0 => Ok(RunSemanticStyle::Plain), - 1 => Ok(RunSemanticStyle::Custom(1)), - 2 => Ok(RunSemanticStyle::Custom(2)), - 3 => Ok(RunSemanticStyle::Custom(3)), - 4 => Ok(RunSemanticStyle::Custom(4)), - 5 => Ok(RunSemanticStyle::Custom(5)), - 128..=255 => Ok(RunSemanticStyle::Custom(value - 128)), - _ => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid run semantic style")), - } -} - -const fn encode_highlight_style(style: Option) -> u8 { - match style { - None => 0, - Some(HighlightStyle::Custom(1)) => 1, - Some(HighlightStyle::Custom(2)) => 2, - Some(HighlightStyle::Custom(3)) => 3, - Some(HighlightStyle::Custom(slot)) => 128 + (slot & 0x7f), - } -} - -#[hotpath::measure] -fn decode_highlight_style(value: u8) -> io::Result> { - Ok(match value { - 0 => None, - 1 => Some(HighlightStyle::Custom(1)), - 2 => Some(HighlightStyle::Custom(2)), - 3 => Some(HighlightStyle::Custom(3)), - 128..=255 => Some(HighlightStyle::Custom(value - 128)), - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "highlight slot is reserved but has no app style yet", - )); - }, - }) -} diff --git a/crates/gpui-flowtext/src/persistence/io.rs b/crates/gpui-flowtext/src/persistence/io.rs deleted file mode 100644 index 2b07ebe0..00000000 --- a/crates/gpui-flowtext/src/persistence/io.rs +++ /dev/null @@ -1,258 +0,0 @@ -use std::{ - collections::hash_map::DefaultHasher, - fs, - hash::{Hash as _, Hasher as _}, - io::{self, Cursor, Read as _, Write as _}, - ops::Range, - path::{Path, PathBuf}, - sync::Arc, - time::Instant, -}; - -use crop::Rope; -use tempfile::NamedTempFile; - -use super::{ - AssetId, AssetRecord, AssetStore, Block, BlockAlignment, BlockId, Document, DocumentIds, DocumentSection, DocumentTheme, EquationBlock, - EquationDisplay, EquationSyntax, HighlightStyle, ImageBlock, ImageSizing, Paragraph, ParagraphId, ParagraphOffsetIndex, ParagraphStyle, - RunSemanticStyle, RunStyles, SectionId, SectionKind, TableBlock, TableCell, TableCellBlock, TableCellParagraph, TableColumnWidth, TableRow, - TableStyle, TextRun, demo_document, document_text_slice, log_timing_lazy, merge_adjacent_runs, paragraph_byte_range, paragraph_index_for_id, - paragraph_runs_len, paragraph_text_len, rebuild_document_offset_index, rebuild_document_sections, reconcile_document_ids, -}; - -// Native binary document format: a magic header, a version, the raw -// UTF-8 text blob, then per-paragraph run metadata. Keeping the format -// length-prefixed makes the reader resilient against trailing junk. -const DOCUMENT_MAGIC: &[u8; 4] = b"GPTX"; -const LEGACY_DOCUMENT_MAGIC: &[u8; 4] = &[b'D', b'B', b'8', 0]; -const DOCUMENT_LEGACY_VERSION: u32 = 5; -const DOCUMENT_VERSION: u32 = 6; - -const CHUNK_TEXT: u8 = 1; -const CHUNK_ASSETS: u8 = 2; -const CHUNK_BLOCKS: u8 = 3; -const CHUNK_PARAGRAPH_IDS: u8 = 4; -const CHUNK_BLOCK_IDS: u8 = 5; -const CHUNK_SECTIONS: u8 = 6; - -const BLOCK_PARAGRAPH: u8 = 0; -const BLOCK_IMAGE: u8 = 1; -const BLOCK_EQUATION: u8 = 2; -const BLOCK_TABLE: u8 = 3; -const TABLE_CELL_PARAGRAPH: u8 = 0; -const TABLE_CELL_TABLE: u8 = 1; - -pub const DEFAULT_DOCUMENT_EXTENSION: &str = "gptx"; - -#[hotpath::measure] -pub fn load_or_create_document(path: impl AsRef) -> io::Result { - let path = path.as_ref(); - match read_document(path) { - Ok(document) => Ok(document), - Err(error) if error.kind() == io::ErrorKind::NotFound => { - let document = demo_document(); - // Best-effort write: if the path is in a read-only directory (e.g. the - // default demo path when the CWD is not writable) we still open the - // demo in memory rather than crashing. - let _ = write_document(path, &document); - Ok(document) - }, - Err(error) => Err(error), - } -} - -#[hotpath::measure] -pub fn read_document(path: impl AsRef) -> io::Result { - let timing = Instant::now(); - let bytes = fs::read(path)?; - read_document_bytes_with_timing(&bytes, timing) -} - -#[hotpath::measure] -pub fn read_document_bytes(bytes: &[u8]) -> io::Result { - read_document_bytes_with_timing(bytes, Instant::now()) -} - -#[hotpath::measure] -fn read_document_bytes_with_timing(bytes: &[u8], timing: Instant) -> io::Result { - let mut cursor = Cursor::new(bytes); - let mut magic = [0; 4]; - cursor.read_exact(&mut magic)?; - if &magic != DOCUMENT_MAGIC && &magic != LEGACY_DOCUMENT_MAGIC { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid document magic")); - } - let version = read_u32(&mut cursor)?; - if version == DOCUMENT_LEGACY_VERSION { - return read_document_current(cursor, timing); - } - if version == DOCUMENT_VERSION { - return read_document_vnext(cursor, timing); - } - Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported document version")) -} - -#[hotpath::measure] -pub fn write_document(path: impl AsRef, document: &Document) -> io::Result<()> { - let path = path.as_ref(); - // Skip directory creation when the parent component is empty (e.g. a bare - // filename like "doc.gptx" with no directory prefix), as create_dir_all("") - // fails on most platforms. write_bytes_atomic handles it identically. - if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { - fs::create_dir_all(parent)?; - } - let document = document_for_serialization(document); - validate_document(&document)?; - let bytes = serialize_document(&document); - write_bytes_atomic(path, &bytes) -} - -#[hotpath::measure] -pub fn document_bytes(document: &Document) -> io::Result> { - let document = document_for_serialization(document); - validate_document(&document)?; - Ok(serialize_document(&document)) -} - -#[hotpath::measure] -fn document_for_serialization(document: &Document) -> Document { - let mut document = document.clone(); - // Recovery/autosave can snapshot while live editing is still settling; make - // sure byte offsets are derived from the paragraph projection we are about - // to serialize instead of trusting cached offsets. - rebuild_document_offset_index(&mut document); - document.blocks = Arc::new(serializable_blocks(&document)); - reconcile_document_ids(&mut document); - rebuild_document_sections(&mut document); - document -} - -#[hotpath::measure] -#[hotpath::measure] -fn serialize_document(document: &Document) -> Vec { - let mut chunks = Vec::<(u8, Vec)>::new(); - let mut text = Vec::with_capacity(document.text.byte_len()); - for chunk in document.text.chunks() { - text.extend_from_slice(chunk.as_bytes()); - } - chunks.push((CHUNK_TEXT, text)); - - let mut assets = Vec::new(); - write_u64(&mut assets, document.assets.assets.len() as u64); - for asset in document.assets.assets.values() { - write_asset_record(&mut assets, asset); - } - chunks.push((CHUNK_ASSETS, assets)); - - let mut blocks = Vec::new(); - write_u64(&mut blocks, document.blocks.len() as u64); - for block in document.blocks.iter() { - write_block_record(&mut blocks, block); - } - chunks.push((CHUNK_BLOCKS, blocks)); - - let mut paragraph_ids = Vec::new(); - write_u64(&mut paragraph_ids, document.ids.paragraph_ids.len() as u64); - for id in &document.ids.paragraph_ids { - write_u128(&mut paragraph_ids, id.0); - } - chunks.push((CHUNK_PARAGRAPH_IDS, paragraph_ids)); - - let mut block_ids = Vec::new(); - write_u64(&mut block_ids, document.ids.block_ids.len() as u64); - for id in &document.ids.block_ids { - write_u128(&mut block_ids, id.0); - } - chunks.push((CHUNK_BLOCK_IDS, block_ids)); - - let mut sections = Vec::new(); - write_u64(&mut sections, document.sections.len() as u64); - for section in document.sections.iter() { - write_section_record(&mut sections, section); - } - chunks.push((CHUNK_SECTIONS, sections)); - - let table_entry_len = 1 + 1 + 2 + 8 + 8; - let header_len = DOCUMENT_MAGIC.len() + std::mem::size_of::() + std::mem::size_of::() + chunks.len() * table_entry_len; - let payload_len = chunks.iter().map(|(_, bytes)| bytes.len()).sum::(); - let mut bytes = Vec::with_capacity(header_len + payload_len); - bytes.extend_from_slice(DOCUMENT_MAGIC); - bytes.extend_from_slice(&DOCUMENT_VERSION.to_le_bytes()); - write_u32( - &mut bytes, - u32::try_from(chunks.len()).expect("native document chunk count is fixed and fits in u32"), - ); - let mut offset = header_len; - for (kind, payload) in &chunks { - bytes.push(*kind); - bytes.push(0); - bytes.extend_from_slice(&0_u16.to_le_bytes()); - write_u64(&mut bytes, offset as u64); - write_u64(&mut bytes, payload.len() as u64); - offset += payload.len(); - } - for (_, payload) in chunks { - bytes.extend_from_slice(&payload); - } - bytes -} - -#[hotpath::measure] -fn serializable_blocks(document: &Document) -> Vec { - let mut paragraph_ix = 0; - let mut blocks = Vec::with_capacity(document.blocks.len().max(document.paragraphs.len())); - - // The current editor mutates the paragraph projection. Rebuild paragraph - // block payloads from that live projection while keeping object/table blocks - // in their structural positions. - for block in document.blocks.iter() { - match block { - Block::Paragraph(_) => { - if let Some(paragraph) = document.paragraphs.get(paragraph_ix) { - let mut paragraph = paragraph.clone(); - paragraph.byte_range = paragraph_byte_range(document, paragraph_ix); - blocks.push(Block::Paragraph(paragraph)); - paragraph_ix += 1; - } - }, - other => blocks.push(other.clone()), - } - } - - while let Some(paragraph) = document.paragraphs.get(paragraph_ix) { - let mut paragraph = paragraph.clone(); - paragraph.byte_range = paragraph_byte_range(document, paragraph_ix); - blocks.push(Block::Paragraph(paragraph)); - paragraph_ix += 1; - } - - blocks -} - -#[hotpath::measure] -fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { - // Use "." as fallback when the path has no directory component (e.g. a bare - // filename) so NamedTempFile::new_in doesn't receive an empty path. - let parent = path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let mut temp = NamedTempFile::new_in(parent)?; - temp.write_all(bytes)?; - temp.as_file_mut().sync_all()?; - let temp_path = temp.into_temp_path(); - #[cfg(target_os = "windows")] - { - // Windows does not allow the POSIX-style atomic replace that tempfile's - // `persist` relies on for existing files. Remove the old target first, - // then rename the fully written temp file into place. This is slightly - // less atomic on Windows, but avoids false "Access is denied" failures - // when saving a normal existing document. - match fs::remove_file(path) { - Ok(()) => {}, - Err(error) if error.kind() == io::ErrorKind::NotFound => {}, - Err(error) => return Err(error), - } - } - temp_path.persist(path).map_err(|error| error.error) -} diff --git a/crates/gpui-flowtext/src/persistence/mod.rs b/crates/gpui-flowtext/src/persistence/mod.rs index 1447f5a4..a86ba7ac 100644 --- a/crates/gpui-flowtext/src/persistence/mod.rs +++ b/crates/gpui-flowtext/src/persistence/mod.rs @@ -1,4 +1,15 @@ -include!("io.rs"); -include!("records.rs"); -include!("validation.rs"); -include!("codec.rs"); +use std::path::{Path, PathBuf}; + +pub const DEFAULT_DOCUMENT_EXTENSION: &str = "db8"; + +#[hotpath::measure] +#[must_use] +pub fn recovery_path_for_document(path: &Path) -> PathBuf { + let mut recovery_path = path.to_path_buf(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map_or_else(|| "untitled.db8.recovery".to_owned(), |name| format!("{name}.recovery")); + recovery_path.set_file_name(file_name); + recovery_path +} diff --git a/crates/gpui-flowtext/src/persistence/records.rs b/crates/gpui-flowtext/src/persistence/records.rs deleted file mode 100644 index a4c0df79..00000000 --- a/crates/gpui-flowtext/src/persistence/records.rs +++ /dev/null @@ -1,778 +0,0 @@ -#[derive(Clone, Copy)] -struct Db8Chunk { - kind: u8, - offset: usize, - len: usize, -} - -#[hotpath::measure] -fn read_document_vnext(mut cursor: Cursor<&[u8]>, timing: Instant) -> io::Result { - let chunk_count = read_u32(&mut cursor)? as usize; - let mut chunks = Vec::with_capacity(chunk_count.min(32)); - for _ in 0..chunk_count { - let kind = read_u8(&mut cursor)?; - let flags = read_u8(&mut cursor)?; - let _reserved = read_u16(&mut cursor)?; - if flags != 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported native document chunk flags")); - } - let offset = read_len(&mut cursor, "native document chunk offset")?; - let len = read_len(&mut cursor, "native document chunk length")?; - chunks.push(Db8Chunk { kind, offset, len }); - } - - let text_bytes = required_chunk(cursor.get_ref(), &chunks, CHUNK_TEXT, "native document text chunk")?; - let text = std::str::from_utf8(text_bytes) - .map(std::borrow::ToOwned::to_owned) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document text chunk is not UTF-8"))?; - let assets = read_assets_chunk(required_chunk(cursor.get_ref(), &chunks, CHUNK_ASSETS, "native document assets chunk")?)?; - let (blocks, paragraphs) = read_blocks_chunk( - required_chunk(cursor.get_ref(), &chunks, CHUNK_BLOCKS, "native document blocks chunk")?, - &text, - )?; - let paragraph_ids = read_paragraph_ids_chunk(required_chunk( - cursor.get_ref(), - &chunks, - CHUNK_PARAGRAPH_IDS, - "native document paragraph IDs chunk", - )?)?; - let block_ids = read_block_ids_chunk(required_chunk( - cursor.get_ref(), - &chunks, - CHUNK_BLOCK_IDS, - "native document block IDs chunk", - )?)?; - let sections = read_sections_chunk(required_chunk( - cursor.get_ref(), - &chunks, - CHUNK_SECTIONS, - "native document sections chunk", - )?)?; - - let offset_index = ParagraphOffsetIndex::new(¶graphs); - let mut document = Document { - text: Rope::from(text), - paragraphs: Arc::new(paragraphs), - blocks: Arc::new(blocks), - assets, - ids: DocumentIds { - document_id: 0, - paragraph_ids, - block_ids, - }, - sections: Arc::new(sections), - offset_index, - theme: DocumentTheme::default(), - }; - reconcile_document_ids(&mut document); - validate_or_rebuild_sections(&mut document); - validate_document(&document)?; - log_timing_lazy("document vnext read", timing, || { - format!( - "bytes={} blocks={} paragraphs={} sections={}", - document.text.byte_len(), - document.blocks.len(), - document.paragraphs.len(), - document.sections.len() - ) - }); - Ok(document) -} - -#[hotpath::measure] -fn required_chunk<'bytes>(bytes: &'bytes [u8], chunks: &[Db8Chunk], kind: u8, label: &'static str) -> io::Result<&'bytes [u8]> { - let chunk = chunks - .iter() - .find(|chunk| chunk.kind == kind) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{label} is missing")))?; - let end = chunk - .offset - .checked_add(chunk.len) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{label} range overflows")))?; - if end > bytes.len() { - return Err(io::Error::new(io::ErrorKind::UnexpectedEof, format!("{label} is truncated"))); - } - Ok(&bytes[chunk.offset..end]) -} - -#[hotpath::measure] -fn read_assets_chunk(bytes: &[u8]) -> io::Result { - let mut cursor = Cursor::new(bytes); - let asset_count = read_len(&mut cursor, "native document asset count")?; - let mut assets = AssetStore::default(); - assets.assets.reserve(asset_count); - for _ in 0..asset_count { - let asset = read_asset_record(&mut cursor)?; - assets.assets.insert(asset.id, asset); - } - Ok(assets) -} - -#[hotpath::measure] -fn read_blocks_chunk(bytes: &[u8], text: &str) -> io::Result<(Vec, Vec)> { - let mut cursor = Cursor::new(bytes); - let block_count = read_len(&mut cursor, "native document block count")?; - let mut blocks = Vec::with_capacity(block_count.min(4096)); - let mut paragraphs = Vec::new(); - for _ in 0..block_count { - let mut block = read_block_record(&mut cursor)?; - normalize_block_text_runs(&mut block, text)?; - if let Block::Paragraph(paragraph) = &block { - paragraphs.push(paragraph.clone()); - } - blocks.push(block); - } - if paragraphs.is_empty() { - paragraphs.push(Paragraph { - style: ParagraphStyle::Normal, - byte_range: 0..0, - runs: Vec::new(), - version: 0, - }); - blocks.push(Block::Paragraph(paragraphs[0].clone())); - } - Ok((blocks, paragraphs)) -} - -#[hotpath::measure] -fn read_paragraph_ids_chunk(bytes: &[u8]) -> io::Result> { - let mut cursor = Cursor::new(bytes); - let count = read_len(&mut cursor, "native document paragraph ID count")?; - let mut ids = Vec::with_capacity(count); - for _ in 0..count { - ids.push(ParagraphId(read_u128(&mut cursor)?)); - } - Ok(ids) -} - -#[hotpath::measure] -fn read_block_ids_chunk(bytes: &[u8]) -> io::Result> { - let mut cursor = Cursor::new(bytes); - let count = read_len(&mut cursor, "native document block ID count")?; - let mut ids = Vec::with_capacity(count); - for _ in 0..count { - ids.push(BlockId(read_u128(&mut cursor)?)); - } - Ok(ids) -} - -#[hotpath::measure] -fn read_sections_chunk(bytes: &[u8]) -> io::Result> { - let mut cursor = Cursor::new(bytes); - let count = read_len(&mut cursor, "native document section count")?; - let mut sections = Vec::with_capacity(count); - for _ in 0..count { - sections.push(read_section_record(&mut cursor)?); - } - Ok(sections) -} - -#[hotpath::measure] -fn read_document_current(mut cursor: Cursor<&[u8]>, timing: Instant) -> io::Result { - let text_len = { - let raw = read_u64(&mut cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document text length overflows usize"))? - }; - let text_bytes = read_bytes(&mut cursor, text_len, "native document text")?; - let text = std::str::from_utf8(text_bytes) - .map(std::borrow::ToOwned::to_owned) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document text is not UTF-8"))?; - - let asset_count = { - let raw = read_u64(&mut cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document asset count overflows usize"))? - }; - let mut assets = AssetStore::default(); - assets.assets.reserve(asset_count); - for _ in 0..asset_count { - let asset = read_asset_record(&mut cursor)?; - assets.assets.insert(asset.id, asset); - } - - let block_count = { - let raw = read_u64(&mut cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document block count overflows usize"))? - }; - let mut blocks = Vec::with_capacity(block_count.min(4096)); - let mut paragraphs = Vec::new(); - for _ in 0..block_count { - let mut block = read_block_record(&mut cursor)?; - normalize_block_text_runs(&mut block, &text)?; - if let Block::Paragraph(paragraph) = &block { - paragraphs.push(paragraph.clone()); - } - blocks.push(block); - } - if paragraphs.is_empty() { - paragraphs.push(Paragraph { - style: ParagraphStyle::Normal, - byte_range: 0..0, - runs: Vec::new(), - version: 0, - }); - blocks.push(Block::Paragraph(paragraphs[0].clone())); - } - - let offset_index = ParagraphOffsetIndex::new(¶graphs); - let mut document = Document { - text: Rope::from(text), - paragraphs: Arc::new(paragraphs), - blocks: Arc::new(blocks), - assets, - ids: DocumentIds::default(), - sections: Arc::new(Vec::new()), - offset_index, - theme: DocumentTheme::default(), - }; - reconcile_document_ids(&mut document); - rebuild_document_sections(&mut document); - validate_document(&document)?; - log_timing_lazy("document read", timing, || { - format!( - "bytes={} blocks={} paragraphs={}", - document.text.byte_len(), - document.blocks.len(), - document.paragraphs.len() - ) - }); - Ok(document) -} - -#[hotpath::measure] -fn validate_or_rebuild_sections(document: &mut Document) { - if document.sections.is_empty() - || document - .sections - .iter() - .any(|section| paragraph_index_for_id(document, section.start_paragraph).is_none()) - { - rebuild_document_sections(document); - } -} - -#[hotpath::measure] -fn normalize_block_text_runs(block: &mut Block, document_text: &str) -> io::Result<()> { - match block { - Block::Paragraph(paragraph) => normalize_paragraph_text_runs(paragraph, document_text), - Block::Image(_) | Block::Equation(_) => Ok(()), - Block::Table(table) => normalize_table_text_runs(table), - } -} - -#[hotpath::measure] -fn normalize_table_text_runs(table: &mut TableBlock) -> io::Result<()> { - for row in &mut table.rows { - for cell in &mut row.cells { - for block in &mut cell.blocks { - match block { - TableCellBlock::Paragraph(paragraph) => normalize_runs_for_text(&mut paragraph.paragraph.runs, ¶graph.text)?, - TableCellBlock::Table(table) => normalize_table_text_runs(table)?, - } - } - } - } - Ok(()) -} - -#[hotpath::measure] -fn normalize_paragraph_text_runs(paragraph: &mut Paragraph, document_text: &str) -> io::Result<()> { - if paragraph.byte_range.start > paragraph.byte_range.end - || paragraph.byte_range.end > document_text.len() - || !document_text.is_char_boundary(paragraph.byte_range.start) - || !document_text.is_char_boundary(paragraph.byte_range.end) - { - return Err(io::Error::new(io::ErrorKind::InvalidData, "paragraph byte range is invalid")); - } - normalize_runs_for_text(&mut paragraph.runs, &document_text[paragraph.byte_range.clone()]) -} - -#[hotpath::measure] -fn normalize_runs_for_text(runs: &mut Vec, text: &str) -> io::Result<()> { - let run_len = runs.iter().map(|run| run.len).sum::(); - if run_len == text.len() && run_boundaries_are_char_boundaries(runs, text) { - return Ok(()); - } - - if run_len == text.chars().count() { - let byte_offsets = char_count_boundaries_to_byte_offsets(runs, text)?; - for (run, range) in runs.iter_mut().zip(byte_offsets.windows(2)) { - run.len = range[1] - range[0]; - } - *runs = merge_adjacent_runs(std::mem::take(runs)); - return Ok(()); - } - - if runs.len() == 1 { - runs[0].len = text.len(); - return Ok(()); - } - - Err(io::Error::new( - io::ErrorKind::InvalidData, - "paragraph run lengths do not match text bytes or characters", - )) -} - -#[hotpath::measure] -fn run_boundaries_are_char_boundaries(runs: &[TextRun], text: &str) -> bool { - let mut byte = 0_usize; - for run in runs { - byte = byte.saturating_add(run.len); - if byte < text.len() && !text.is_char_boundary(byte) { - return false; - } - } - byte == text.len() -} - -#[hotpath::measure] -fn char_count_boundaries_to_byte_offsets(runs: &[TextRun], text: &str) -> io::Result> { - let mut offsets = Vec::with_capacity(runs.len() + 1); - offsets.push(0); - let mut char_count = 0_usize; - for run in runs { - char_count = char_count.saturating_add(run.len); - if char_count == text.chars().count() { - offsets.push(text.len()); - continue; - } - let Some((byte, _)) = text.char_indices().nth(char_count) else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "run character offset is outside paragraph text", - )); - }; - offsets.push(byte); - } - Ok(offsets) -} - -#[hotpath::measure] -fn read_block_record(cursor: &mut Cursor<&[u8]>) -> io::Result { - let kind = read_u8(cursor)?; - let payload_len = { - let raw = read_u64(cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document block payload length overflows usize"))? - }; - let payload = read_bytes(cursor, payload_len, "native document block payload")?; - let mut payload = Cursor::new(payload); - match kind { - BLOCK_PARAGRAPH => read_paragraph_payload(&mut payload).map(Block::Paragraph), - BLOCK_IMAGE => read_image_payload(&mut payload).map(Block::Image), - BLOCK_EQUATION => read_equation_payload(&mut payload).map(Block::Equation), - BLOCK_TABLE => read_table_payload(&mut payload).map(Block::Table), - _ => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid native document block kind")), - } -} - -#[hotpath::measure] -fn write_block_record(bytes: &mut Vec, block: &Block) { - let mut payload = Vec::new(); - let kind = match block { - Block::Paragraph(paragraph) => { - write_paragraph_payload(&mut payload, paragraph, paragraph.byte_range.clone()); - BLOCK_PARAGRAPH - }, - Block::Image(image) => { - write_image_payload(&mut payload, image); - BLOCK_IMAGE - }, - Block::Equation(equation) => { - write_equation_payload(&mut payload, equation); - BLOCK_EQUATION - }, - Block::Table(table) => { - write_table_payload(&mut payload, table); - BLOCK_TABLE - }, - }; - bytes.push(kind); - write_u64(bytes, payload.len() as u64); - bytes.extend_from_slice(&payload); -} - -#[hotpath::measure] -fn write_section_record(bytes: &mut Vec, section: &DocumentSection) { - write_u128(bytes, section.id.0); - write_u128(bytes, section.parent_id.map_or(0, |id| id.0)); - bytes.push(encode_section_kind(section.kind)); - bytes.push(u8::from(section.heading_paragraph.is_some())); - bytes.push(u8::from(section.end_paragraph_exclusive.is_some())); - bytes.push(0); - write_u128(bytes, section.heading_paragraph.map_or(0, |id| id.0)); - write_u128(bytes, section.start_paragraph.0); - write_u128(bytes, section.end_paragraph_exclusive.map_or(0, |id| id.0)); -} - -#[hotpath::measure] -fn read_section_record(cursor: &mut Cursor<&[u8]>) -> io::Result { - let id = SectionId(read_u128(cursor)?); - let parent = read_u128(cursor)?; - let kind = decode_section_kind(read_u8(cursor)?)?; - let has_heading = read_u8(cursor)? != 0; - let has_end = read_u8(cursor)? != 0; - let reserved = read_u8(cursor)?; - if reserved != 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid native document section reserved byte", - )); - } - let heading = read_u128(cursor)?; - let start = read_u128(cursor)?; - let end = read_u128(cursor)?; - Ok(DocumentSection { - id, - parent_id: (parent != 0).then_some(SectionId(parent)), - kind, - heading_paragraph: has_heading.then_some(ParagraphId(heading)), - start_paragraph: ParagraphId(start), - end_paragraph_exclusive: has_end.then_some(ParagraphId(end)), - }) -} - -#[hotpath::measure] -fn read_paragraph_payload(cursor: &mut Cursor<&[u8]>) -> io::Result { - let style = decode_paragraph_style(read_u8(cursor)?)?; - let start = { - let raw = read_u64(cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document paragraph start overflows usize"))? - }; - let end = { - let raw = read_u64(cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document paragraph end overflows usize"))? - }; - let run_count = { - let raw = read_u64(cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document run count overflows usize"))? - }; - let mut runs = Vec::with_capacity(run_count.min(4096)); - for _ in 0..run_count { - let len = { - let raw = read_u64(cursor)?; - usize::try_from(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "native document run length overflows usize"))? - }; - let styles = read_run_styles(cursor)?; - runs.push(TextRun { len, styles }); - } - Ok(Paragraph { - style, - byte_range: start..end, - runs: merge_adjacent_runs(runs), - version: 0, - }) -} - -#[hotpath::measure] -fn write_paragraph_payload(bytes: &mut Vec, paragraph: &Paragraph, range: Range) { - bytes.push(encode_paragraph_style(paragraph.style)); - write_u64(bytes, range.start as u64); - write_u64(bytes, range.end as u64); - write_u64(bytes, paragraph.runs.len() as u64); - for run in ¶graph.runs { - write_u64(bytes, run.len as u64); - write_run_styles(bytes, run.styles); - } -} - -#[hotpath::measure] -fn read_image_payload(cursor: &mut Cursor<&[u8]>) -> io::Result { - let asset_id = AssetId(read_u128(cursor)?); - let alt_text = read_string(cursor)?.into(); - let caption = if read_u8(cursor)? == 1 { - Some(read_paragraph_payload(cursor)?) - } else { - None - }; - let sizing = match read_u8(cursor)? { - 0 => ImageSizing::Intrinsic, - 1 => ImageSizing::FitWidth, - 2 => { - let width_px = read_u32(cursor)?; - let height_px = if read_u8(cursor)? == 1 { Some(read_u32(cursor)?) } else { None }; - ImageSizing::Fixed { width_px, height_px } - }, - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid image sizing")), - }; - let alignment = decode_block_alignment(read_u8(cursor)?)?; - Ok(ImageBlock { - asset_id, - alt_text, - caption, - sizing, - alignment, - version: 0, - }) -} - -#[hotpath::measure] -fn write_image_payload(bytes: &mut Vec, image: &ImageBlock) { - write_u128(bytes, image.asset_id.0); - write_string(bytes, image.alt_text.as_ref()); - match &image.caption { - Some(caption) => { - bytes.push(1); - write_paragraph_payload(bytes, caption, caption.byte_range.clone()); - }, - None => bytes.push(0), - } - match image.sizing { - ImageSizing::Intrinsic => bytes.push(0), - ImageSizing::FitWidth => bytes.push(1), - ImageSizing::Fixed { width_px, height_px } => { - bytes.push(2); - bytes.extend_from_slice(&width_px.to_le_bytes()); - match height_px { - Some(height_px) => { - bytes.push(1); - bytes.extend_from_slice(&height_px.to_le_bytes()); - }, - None => bytes.push(0), - } - }, - } - bytes.push(encode_block_alignment(image.alignment)); -} - -#[hotpath::measure] -fn read_equation_payload(cursor: &mut Cursor<&[u8]>) -> io::Result { - let syntax = match read_u8(cursor)? { - 0 => EquationSyntax::Latex, - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid equation syntax")), - }; - let display = match read_u8(cursor)? { - 0 => EquationDisplay::Display, - 1 => EquationDisplay::InlineLikeParagraph, - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid equation display")), - }; - Ok(EquationBlock { - source: read_string(cursor)?.into(), - syntax, - display, - version: 0, - }) -} - -#[hotpath::measure] -fn write_equation_payload(bytes: &mut Vec, equation: &EquationBlock) { - bytes.push(match equation.syntax { - EquationSyntax::Latex => 0, - }); - bytes.push(match equation.display { - EquationDisplay::Display => 0, - EquationDisplay::InlineLikeParagraph => 1, - }); - write_string(bytes, equation.source.as_ref()); -} - -#[hotpath::measure] -fn read_table_payload(cursor: &mut Cursor<&[u8]>) -> io::Result { - let column_count = read_len(cursor, "native document table column count")?; - let mut column_widths = Vec::with_capacity(column_count.min(64)); - for _ in 0..column_count { - column_widths.push(match read_u8(cursor)? { - 0 => TableColumnWidth::Auto, - 1 => TableColumnWidth::FixedPx(read_u32(cursor)?), - 2 => TableColumnWidth::Fraction(read_u32(cursor)?), - _ => return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid table column width")), - }); - } - let header_row = read_u8(cursor)? != 0; - let row_count = read_len(cursor, "native document table row count")?; - let mut rows = Vec::with_capacity(row_count.min(4096)); - for _ in 0..row_count { - let cell_count = read_len(cursor, "native document table cell count")?; - let mut cells = Vec::with_capacity(cell_count.min(128)); - for _ in 0..cell_count { - let row_span = read_u16(cursor)?; - let col_span = read_u16(cursor)?; - let block_count = read_len(cursor, "native document table cell block count")?; - let mut blocks = Vec::with_capacity(block_count.min(64)); - for _ in 0..block_count { - blocks.push(read_table_cell_block(cursor)?); - } - cells.push(TableCell { blocks, row_span, col_span }); - } - rows.push(TableRow { cells }); - } - Ok(TableBlock { - rows, - column_widths, - style: TableStyle { header_row }, - version: 0, - }) -} - -#[hotpath::measure] -fn write_table_payload(bytes: &mut Vec, table: &TableBlock) { - write_u64(bytes, table.column_widths.len() as u64); - for width in &table.column_widths { - match *width { - TableColumnWidth::Auto => bytes.push(0), - TableColumnWidth::FixedPx(px) => { - bytes.push(1); - bytes.extend_from_slice(&px.to_le_bytes()); - }, - TableColumnWidth::Fraction(fraction) => { - bytes.push(2); - bytes.extend_from_slice(&fraction.to_le_bytes()); - }, - } - } - bytes.push(u8::from(table.style.header_row)); - write_u64(bytes, table.rows.len() as u64); - for row in &table.rows { - write_u64(bytes, row.cells.len() as u64); - for cell in &row.cells { - bytes.extend_from_slice(&cell.row_span.to_le_bytes()); - bytes.extend_from_slice(&cell.col_span.to_le_bytes()); - write_u64(bytes, cell.blocks.len() as u64); - for block in &cell.blocks { - write_table_cell_block(bytes, block); - } - } - } -} - -#[hotpath::measure] -fn read_table_cell_block(cursor: &mut Cursor<&[u8]>) -> io::Result { - match read_u8(cursor)? { - TABLE_CELL_PARAGRAPH => { - let text = read_string(cursor)?; - let paragraph = read_paragraph_payload(cursor)?; - Ok(TableCellBlock::Paragraph(TableCellParagraph { paragraph, text })) - }, - TABLE_CELL_TABLE => read_table_payload(cursor).map(TableCellBlock::Table), - _ => Err(io::Error::new(io::ErrorKind::InvalidData, "invalid table cell block kind")), - } -} - -#[hotpath::measure] -fn write_table_cell_block(bytes: &mut Vec, block: &TableCellBlock) { - match block { - TableCellBlock::Paragraph(paragraph) => { - bytes.push(TABLE_CELL_PARAGRAPH); - write_string(bytes, ¶graph.text); - write_paragraph_payload(bytes, ¶graph.paragraph, 0..paragraph.text.len()); - }, - TableCellBlock::Table(table) => { - bytes.push(TABLE_CELL_TABLE); - write_table_payload(bytes, table); - }, - } -} - -#[hotpath::measure] -fn read_asset_record(cursor: &mut Cursor<&[u8]>) -> io::Result { - let id = AssetId(read_u128(cursor)?); - let mime_type = read_string(cursor)?.into(); - let original_name = if read_u8(cursor)? == 1 { - Some(read_string(cursor)?.into()) - } else { - None - }; - let content_hash = read_u64(cursor)?; - let byte_len = read_len(cursor, "native document asset byte length")?; - let bytes = read_bytes(cursor, byte_len, "native document asset bytes")?.to_vec(); - Ok(AssetRecord { - id, - mime_type, - original_name, - content_hash, - bytes: Arc::new(bytes), - }) -} - -#[hotpath::measure] -fn write_asset_record(bytes: &mut Vec, asset: &AssetRecord) { - write_u128(bytes, asset.id.0); - write_string(bytes, asset.mime_type.as_ref()); - match &asset.original_name { - Some(name) => { - bytes.push(1); - write_string(bytes, name.as_ref()); - }, - None => bytes.push(0), - } - write_u64(bytes, asset.content_hash); - write_u64(bytes, asset.bytes.len() as u64); - bytes.extend_from_slice(&asset.bytes); -} - -#[hotpath::measure] -#[must_use] -pub fn recovery_path_for_document(path: &Path) -> PathBuf { - let mut recovery_path = path.to_path_buf(); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .map_or_else(|| "untitled.document.recovery".to_owned(), |name| format!("{name}.recovery")); - recovery_path.set_file_name(file_name); - recovery_path -} - -#[cfg(test)] -mod records_tests { - use super::*; - use crate::{InputParagraph, InputRun}; - - #[test] - fn normalizes_legacy_character_count_run_lengths() { - let mut runs = vec![TextRun { - len: "Kepe et al. ‘23".chars().count(), - styles: RunStyles::default(), - }]; - - normalize_runs_for_text(&mut runs, "Kepe et al. ‘23").expect("normalize runs"); - - assert_eq!(runs[0].len, "Kepe et al. ‘23".len()); - } - - #[test] - fn normalizes_legacy_character_count_run_boundaries() { - let cite = RunStyles { - semantic: RunSemanticStyle::Custom(1), - ..RunStyles::default() - }; - let mut runs = vec![ - TextRun { - len: "Kepe et al. ".chars().count(), - styles: RunStyles::default(), - }, - TextRun { - len: "‘23".chars().count(), - styles: cite, - }, - ]; - - normalize_runs_for_text(&mut runs, "Kepe et al. ‘23").expect("normalize runs"); - - assert_eq!(runs[0].len, "Kepe et al. ".len()); - assert_eq!(runs[1].len, "‘23".len()); - } - - #[test] - fn custom_style_slots_round_trip_through_document() { - let document = crate::document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Custom(7), - runs: vec![InputRun { - text: "custom".to_string(), - styles: RunStyles { - semantic: RunSemanticStyle::Custom(9), - highlight: Some(HighlightStyle::Custom(11)), - ..RunStyles::default() - }, - }], - }], - ); - - let bytes = crate::document_bytes(&document).expect("serialize custom styles"); - let loaded = crate::read_document_bytes(&bytes).expect("read custom styles"); - - assert_eq!(loaded.paragraphs[0].style, ParagraphStyle::Custom(7)); - assert_eq!(loaded.paragraphs[0].runs[0].styles.semantic, RunSemanticStyle::Custom(9)); - assert_eq!(loaded.paragraphs[0].runs[0].styles.highlight, Some(HighlightStyle::Custom(11))); - } -} diff --git a/crates/gpui-flowtext/src/persistence/validation.rs b/crates/gpui-flowtext/src/persistence/validation.rs deleted file mode 100644 index 60a91db1..00000000 --- a/crates/gpui-flowtext/src/persistence/validation.rs +++ /dev/null @@ -1,230 +0,0 @@ - -#[allow(dead_code, reason = "Persistence validation is kept available for debug and importer validation paths.")] -#[hotpath::measure] -fn document_fingerprint(document: &Document) -> u64 { - let mut hasher = DefaultHasher::new(); - document_text_slice(document, 0..document.text.byte_len()).hash(&mut hasher); - for (paragraph_ix, paragraph) in document.paragraphs.iter().enumerate() { - let range = paragraph_byte_range(document, paragraph_ix); - paragraph.style.hash(&mut hasher); - range.start.hash(&mut hasher); - range.end.hash(&mut hasher); - paragraph.runs.hash(&mut hasher); - } - hasher.finish() -} - -#[hotpath::measure] -fn validate_document(document: &Document) -> io::Result<()> { - let text_len = document.text.byte_len(); - if document.paragraphs.is_empty() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "native document has no paragraphs")); - } - if document.ids.paragraph_ids.len() != document.paragraphs.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "paragraph ID count does not match paragraph count", - )); - } - if document.ids.block_ids.len() != document.blocks.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "block ID count does not match block count", - )); - } - for (ix, paragraph) in document.paragraphs.iter().enumerate() { - let range = paragraph_byte_range(document, ix); - if range.start > range.end || range.end > text_len { - return Err(io::Error::new(io::ErrorKind::InvalidData, "paragraph range is outside document text")); - } - if ix == 0 && range.start != 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "first paragraph does not start at byte 0")); - } - if paragraph_runs_len(paragraph) != paragraph_text_len(paragraph) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "paragraph run lengths do not match paragraph text", - )); - } - // Verify every run boundary falls on a valid UTF-8 char boundary. A - // corrupt native document could declare correct total run lengths but split a - // multibyte character mid-codepoint, which would panic when layout - // later slices the rope at those offsets. - { - let p_text = document_text_slice(document, range.clone()); - let mut run_end = 0; - for run in ¶graph.runs { - run_end += run.len; - if run_end < p_text.len() && !p_text.is_char_boundary(run_end) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "run boundary splits a UTF-8 character")); - } - } - } - if ix > 0 { - let previous_range = paragraph_byte_range(document, ix - 1); - if previous_range.end + 1 != range.start || document.text.byte(previous_range.end) != b'\n' { - return Err(io::Error::new(io::ErrorKind::InvalidData, "paragraph ranges are not newline separated")); - } - } - } - if document - .paragraphs - .last() - .is_some_and(|_| paragraph_byte_range(document, document.paragraphs.len() - 1).end != text_len) - { - return Err(io::Error::new(io::ErrorKind::InvalidData, "last paragraph does not end at text length")); - } - validate_paragraph_block_projection(document)?; - validate_sections(document)?; - for block in document.blocks.iter() { - validate_block_payload(block, document, 0)?; - } - Ok(()) -} - -#[hotpath::measure] -fn validate_sections(document: &Document) -> io::Result<()> { - for section in document.sections.iter() { - if paragraph_index_for_id(document, section.start_paragraph).is_none() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "section start paragraph ID is invalid")); - } - if let Some(heading) = section.heading_paragraph - && paragraph_index_for_id(document, heading).is_none() - { - return Err(io::Error::new(io::ErrorKind::InvalidData, "section heading paragraph ID is invalid")); - } - if let Some(end) = section.end_paragraph_exclusive - && paragraph_index_for_id(document, end).is_none() - { - return Err(io::Error::new(io::ErrorKind::InvalidData, "section end paragraph ID is invalid")); - } - } - Ok(()) -} - -#[hotpath::measure] -fn validate_paragraph_block_projection(document: &Document) -> io::Result<()> { - let paragraph_blocks = document - .blocks - .iter() - .filter_map(|block| match block { - Block::Paragraph(paragraph) => Some(paragraph), - Block::Image(_) | Block::Equation(_) | Block::Table(_) => None, - }) - .collect::>(); - if paragraph_blocks.len() != document.paragraphs.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "paragraph block count does not match paragraph metadata", - )); - } - for (block_paragraph, paragraph) in paragraph_blocks.iter().zip(document.paragraphs.iter()) { - if *block_paragraph != paragraph { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "paragraph block payload does not match paragraph metadata", - )); - } - } - Ok(()) -} - -#[hotpath::measure] -fn validate_block_payload(block: &Block, document: &Document, table_depth: usize) -> io::Result<()> { - match block { - // Missing assets are allowed so a partially damaged document can still - // open and show a visible missing-image block instead of failing load. - Block::Image(image) => validate_image_payload(image, document)?, - Block::Equation(equation) => validate_equation_payload(equation)?, - Block::Table(table) => validate_table_payload(table, table_depth)?, - Block::Paragraph(paragraph) => { - if paragraph_runs_len(paragraph) != paragraph_text_len(paragraph) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "paragraph block run lengths are invalid")); - } - }, - } - Ok(()) -} - -#[hotpath::measure] -fn validate_image_payload(image: &ImageBlock, document: &Document) -> io::Result<()> { - match image.sizing { - ImageSizing::Fixed { width_px, height_px } => { - if width_px == 0 || height_px == Some(0) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "fixed image dimensions must be nonzero")); - } - }, - ImageSizing::Intrinsic | ImageSizing::FitWidth => {}, - } - if let Some(caption) = &image.caption - && paragraph_runs_len(caption) != paragraph_text_len(caption) - { - return Err(io::Error::new(io::ErrorKind::InvalidData, "image caption run lengths are invalid")); - } - if let Some(asset) = document.assets.assets.get(&image.asset_id) { - let mut hasher = DefaultHasher::new(); - asset.bytes.hash(&mut hasher); - if asset.content_hash != hasher.finish() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "image asset content hash mismatch")); - } - } - Ok(()) -} - -#[hotpath::measure] -fn validate_equation_payload(equation: &EquationBlock) -> io::Result<()> { - if equation.source.len() > 64 * 1024 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "equation source is too large")); - } - Ok(()) -} - -#[hotpath::measure] -fn validate_table_payload(table: &TableBlock, depth: usize) -> io::Result<()> { - if depth > 8 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "nested tables are too deep")); - } - if table.rows.is_empty() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "table has no rows")); - } - let expected_columns = table.column_widths.len().max(1); - for row in &table.rows { - if row.cells.is_empty() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "table row has no cells")); - } - let mut span_total = 0_usize; - for cell in &row.cells { - if cell.row_span == 0 || cell.col_span == 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "table cell span cannot be zero")); - } - span_total = span_total.saturating_add(cell.col_span as usize); - for block in &cell.blocks { - match block { - TableCellBlock::Paragraph(paragraph) => { - if paragraph.paragraph.byte_range != (0..paragraph.text.len()) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "table cell paragraph byte range is invalid")); - } - if paragraph_runs_len(¶graph.paragraph) != paragraph.text.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "table cell paragraph run lengths do not match text", - )); - } - let mut run_end = 0; - for run in ¶graph.paragraph.runs { - run_end += run.len; - if run_end < paragraph.text.len() && !paragraph.text.is_char_boundary(run_end) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "table cell run boundary splits UTF-8")); - } - } - }, - TableCellBlock::Table(nested) => validate_table_payload(nested, depth + 1)?, - } - } - } - if span_total != expected_columns { - return Err(io::Error::new(io::ErrorKind::InvalidData, "table row shape does not match column count")); - } - } - Ok(()) -} diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs index 762613d3..459325e4 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs @@ -1,6 +1,6 @@ #[hotpath::measure] fn benchmark_layout_paths( - document: &Document, + document: &DocumentProjection, widths: &[f32], iterations: usize, include_paint: bool, @@ -40,7 +40,7 @@ fn benchmark_layout_paths( let paint_bounds = Bounds::new(point(px(0.0), px(0.0)), size(width_px, layout.size.height)); let paint_plain = include_paint.then(|| { repeated(iterations, || { - paint_layout(&layout, paint_bounds, None, None, false, px(1.0), &[], &[], None, window, cx); + paint_layout(&layout, paint_bounds, None, None, false, px(1.0), None, &[], &[], None, window, cx); }) }); let selection = top_selection(document); @@ -53,6 +53,7 @@ fn benchmark_layout_paths( None, false, px(1.0), + None, &[], &[], None, @@ -98,7 +99,7 @@ fn benchmark_layout_paths( #[hotpath::measure] fn benchmark_sample_paragraph_layouts( - document: &Document, + document: &DocumentProjection, stats: &DocumentStats, widths: &[f32], iterations: usize, @@ -179,7 +180,7 @@ fn repeated(iterations: usize, mut run: impl FnMut()) -> DurationStats { } #[hotpath::measure] -fn estimate_error(document: &Document, layout: &LayoutState, width: Pixels) -> (f32, f32) { +fn estimate_error(document: &DocumentProjection, layout: &LayoutState, width: Pixels) -> (f32, f32) { let mut total = 0.0f32; let mut max = 0.0f32; let mut count = 0usize; @@ -199,7 +200,7 @@ fn estimate_error(document: &Document, layout: &LayoutState, width: Pixels) -> ( } #[hotpath::measure] -fn summarize_layout(document: &Document, layout: &LayoutState) -> LayoutSummary { +fn summarize_layout(document: &DocumentProjection, layout: &LayoutState) -> LayoutSummary { let mut summary = LayoutSummary { layout_height: px_to_f32(layout.size.height), ..Default::default() diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/operations.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/operations.rs index 762b150c..05802352 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/operations.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/operations.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -fn benchmark_index_paths(document: &Document, iterations: usize) -> Vec { +fn benchmark_index_paths(document: &DocumentProjection, iterations: usize) -> Vec { let mut rows = Vec::new(); rows.push(operation_row("paragraph_byte_range all", iterations, || { let mut failures = 0; @@ -61,7 +61,7 @@ fn benchmark_index_paths(document: &Document, iterations: usize) -> Vec Vec { +fn benchmark_edit_paths(document: &DocumentProjection, stats: &DocumentStats, iterations: usize) -> Vec { let mut rows = Vec::new(); let largest = stats .largest_paragraph_ix diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/reports.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/reports.rs index 03894409..c8a4d406 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/reports.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/reports.rs @@ -88,62 +88,3 @@ fn write_fidelity_report(out: &mut String, fidelity: &FidelityReport) { } let _ = writeln!(out); } - -#[hotpath::measure] -fn write_roundtrip_report(out: &mut String, loaded: &LoadedDocument, iterations: usize) { - let mut write_timings = Vec::with_capacity(iterations); - let mut read_timings = Vec::with_capacity(iterations); - let mut fingerprint_matches = true; - let mut last_error = None; - let original = fingerprint_document(&loaded.document); - - for _ in 0..iterations { - let result = (|| { - let dir = tempdir().map_err(|error| error.to_string())?; - let path = dir.path().join("roundtrip.gptx"); - let write_start = Instant::now(); - write_document(&path, &loaded.document).map_err(|error| error.to_string())?; - write_timings.push(write_start.elapsed()); - let read_start = Instant::now(); - let roundtrip = read_document(&path).map_err(|error| error.to_string())?; - read_timings.push(read_start.elapsed()); - fingerprint_matches &= fingerprint_document(&roundtrip) == original; - Ok::<_, String>(()) - })(); - if let Err(error) = result { - last_error = Some(error); - break; - } - } - - let _ = writeln!(out, "### Native Document Roundtrip"); - let _ = writeln!(out); - match last_error { - Some(error) => { - let _ = writeln!(out, "- status: `failed`"); - let _ = writeln!(out, "- error: `{}`", md(&error)); - }, - None => { - let write_stats = DurationStats::from_samples(&write_timings); - let read_stats = DurationStats::from_samples(&read_timings); - let _ = writeln!(out, "| phase | min ms | mean ms | max ms | fingerprint match |"); - let _ = writeln!(out, "|---|---:|---:|---:|---|"); - let _ = writeln!( - out, - "| write_document | {:.3} | {:.3} | {:.3} | n/a |", - ms(write_stats.min), - ms(write_stats.mean), - ms(write_stats.max) - ); - let _ = writeln!( - out, - "| read_document roundtrip | {:.3} | {:.3} | {:.3} | {} |", - ms(read_stats.min), - ms(read_stats.mean), - ms(read_stats.max), - fingerprint_matches - ); - }, - } - let _ = writeln!(out); -} diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/runner.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/runner.rs index 925d5602..8db34316 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/runner.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/runner.rs @@ -9,8 +9,6 @@ use std::{ }; use gpui::{Bounds, Context, IntoElement, Pixels, Render, Window, div, point, prelude::*, px, size}; -use tempfile::tempdir; - use super::*; const DEFAULT_WIDTHS: &[f32] = &[720.0, 900.0, 1100.0, 1440.0]; @@ -143,7 +141,7 @@ struct LoadedDocument { label: String, path: Option, file_bytes: Option, - document: Document, + document: DocumentProjection, load: DurationStats, } diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/sources_stats.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/sources_stats.rs index 9509ca70..2a0084f3 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/sources_stats.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/sources_stats.rs @@ -9,19 +9,7 @@ fn benchmark_sources(options: &BenchmarkOptions) -> Vec { .collect(); } - let mut paths = fs::read_dir(".") - .ok() - .into_iter() - .flat_map(|entries| entries.filter_map(Result::ok)) - .map(|entry| entry.path()) - .filter(|path| path.extension().is_some_and(|extension| extension == DEFAULT_DOCUMENT_EXTENSION)) - .collect::>(); - paths.sort(); - if paths.is_empty() { - vec![BenchmarkSource::Demo] - } else { - paths.into_iter().map(BenchmarkSource::Path).collect() - } + vec![BenchmarkSource::Demo] } #[hotpath::measure] @@ -33,7 +21,12 @@ fn load_document_source(source: &BenchmarkSource, iterations: usize) -> Result read_document(path).map_err(|error| error.to_string())?, + BenchmarkSource::Path(path) => { + return Err(format!( + "loading {} requires a host Loro package adapter", + path.display() + )); + }, BenchmarkSource::Demo => demo_document(), }; timings.push(started.elapsed()); @@ -66,7 +59,7 @@ fn source_label(source: &BenchmarkSource) -> String { } #[hotpath::measure] -fn document_stats(document: &Document) -> DocumentStats { +fn document_stats(document: &DocumentProjection) -> DocumentStats { let mut stats = DocumentStats { text_bytes: document.text.byte_len(), text_chars: full_document_text(document).chars().count(), @@ -151,7 +144,7 @@ fn accumulate_table_stats(table: &TableBlock, stats: &mut DocumentStats, nested: } #[hotpath::measure] -fn check_document_fidelity(document: &Document) -> FidelityReport { +fn check_document_fidelity(document: &DocumentProjection) -> FidelityReport { let mut report = FidelityReport::default(); let full_text = full_document_text(document); report.check(!document.paragraphs.is_empty(), "document must contain at least one paragraph"); diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/suite.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/suite.rs index 04a1bf4a..99e758d5 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/suite.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/suite.rs @@ -31,7 +31,7 @@ fn run_benchmark_suite(options: &BenchmarkOptions, window: &mut Window, cx: &mut let sources = benchmark_sources(options); if sources.is_empty() { - let _ = writeln!(report, "No `.gptx` files or explicit benchmark documents were found."); + let _ = writeln!(report, "No explicit benchmark documents were found."); return report; } @@ -157,7 +157,6 @@ fn benchmark_document( write_stats_tables(&mut out, stats); write_fidelity_report(&mut out, &fidelity); - write_roundtrip_report(&mut out, loaded, iterations); let index_rows = benchmark_index_paths(document, iterations); write_operation_table(&mut out, "Index And Mapping Benchmarks", &index_rows); diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/utilities.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/utilities.rs index 0072d69c..66aaf02b 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/utilities.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/utilities.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -fn first_window_range(document: &Document, paragraph_count: usize) -> Range { +fn first_window_range(document: &DocumentProjection, paragraph_count: usize) -> Range { let end_paragraph = document .paragraphs .len() @@ -12,25 +12,25 @@ fn first_window_range(document: &Document, paragraph_count: usize) -> Range Option { +fn top_selection(document: &DocumentProjection) -> Option { if document.paragraphs.is_empty() { return None; } - Some(EditorSelection { - anchor: DocumentOffset { paragraph: 0, byte: 0 }, - head: first_window_range(document, 3).end, - }) + Some(EditorSelection::range( + DocumentOffset { paragraph: 0, byte: 0 }, + first_window_range(document, 3).end, + )) } #[hotpath::measure] -fn first_char_range(document: &Document, paragraph_ix: usize) -> Option> { +fn first_char_range(document: &DocumentProjection, paragraph_ix: usize) -> Option> { let text = paragraph_text(document, paragraph_ix); let ch = text.chars().next()?; Some(0..ch.len_utf8()) } #[hotpath::measure] -fn safe_mid_byte(document: &Document, paragraph_ix: usize) -> usize { +fn safe_mid_byte(document: &DocumentProjection, paragraph_ix: usize) -> usize { let text = paragraph_text(document, paragraph_ix); if text.is_empty() { return 0; @@ -45,7 +45,7 @@ fn safe_mid_byte(document: &Document, paragraph_ix: usize) -> usize { } #[hotpath::measure] -fn fingerprint_document(document: &Document) -> u64 { +fn fingerprint_document(document: &DocumentProjection) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); for chunk in document.text.chunks() { chunk.hash(&mut hasher); diff --git a/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs b/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs index 267e82ec..dd4f6039 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs @@ -233,24 +233,30 @@ impl RichTextEditor { key_char.to_string() }; if self.insert_text_into_selected_table_cell(&key_char, cx) { + cx.stop_propagation(); return; } if self.insert_text_into_selected_equation(&key_char, cx) { + cx.stop_propagation(); return; } self.insert_text_command(&key_char, cx); + cx.stop_propagation(); } #[cfg(not(target_os = "windows"))] { let _ = window; if self.insert_text_into_selected_table_cell(key_char, cx) { + cx.stop_propagation(); return; } if self.insert_text_into_selected_equation(key_char, cx) { + cx.stop_propagation(); return; } self.insert_text_command(key_char, cx); + cx.stop_propagation(); } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/block_helpers.rs b/crates/gpui-flowtext/src/rich_text/editor/block_helpers.rs index aeaaa87c..4f10590a 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/block_helpers.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/block_helpers.rs @@ -1,6 +1,6 @@ #[hotpath::measure] fn insert_standalone_paragraphs_into_projection( - document: &mut Document, + document: &mut DocumentProjection, insert_paragraph_ix: usize, inserted: &[InputParagraph], ) -> Vec { diff --git a/crates/gpui-flowtext/src/rich_text/editor/block_insertion.rs b/crates/gpui-flowtext/src/rich_text/editor/block_insertion.rs index bb01a648..9813fab0 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/block_insertion.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/block_insertion.rs @@ -49,7 +49,8 @@ impl RichTextEditor { self.delete_selection_internal(); } let caret = insert_rich_fragment_at(&mut self.document, self.selection.head, &fragment); - self.selection = EditorSelection { anchor: caret, head: caret }; + self.selection = EditorSelection::collapsed(caret); + self.emit_selection_changed(cx); self.after_text_mutation(cx); } @@ -71,12 +72,19 @@ impl RichTextEditor { }, ); } - self.insert_ordered_block_fragment_after_caret(&fragment.blocks); - self.push_replace_document_history(before_document, before_selection, cx); + let inserted_range = self.insert_ordered_block_fragment_after_caret(&fragment.blocks, cx); + let semantic_commands = self.insert_block_semantic_commands(&before_document, &before_selection, inserted_range); + self.push_document_snapshot_history( + before_document, + before_selection, + semantic_commands.unwrap_or_default(), + cx, + ); } - fn insert_ordered_block_fragment_after_caret(&mut self, input_blocks: &[InputBlock]) { - let insert_ix = self.prepare_block_insertion_index(); + fn insert_ordered_block_fragment_after_caret(&mut self, input_blocks: &[InputBlock], cx: &mut Context) -> Range { + let remove_empty_caret_paragraph = !input_blocks.iter().any(|block| matches!(block, InputBlock::Paragraph(_))); + let insert_ix = self.prepare_block_insertion_index(remove_empty_caret_paragraph, cx); let insert_paragraph_ix = self .document .blocks @@ -147,6 +155,7 @@ impl RichTextEditor { self.clear_layout_work_caches(); self.item_sizes_cache = None; self.paragraph_height_cache_revision = self.paragraph_height_cache_revision.wrapping_add(1); + insert_ix..insert_ix + input_blocks.len() } fn insert_blocks_after_caret(&mut self, blocks: Vec, cx: &mut Context) { @@ -155,15 +164,22 @@ impl RichTextEditor { } let before_document = self.document.clone(); let before_selection = self.selection.clone(); - self.insert_blocks_after_caret_without_history(blocks); - self.push_replace_document_history(before_document, before_selection, cx); + let inserted_range = self.insert_blocks_after_caret_without_history(blocks, cx); + let semantic_commands = self.insert_block_semantic_commands(&before_document, &before_selection, inserted_range); + self.push_document_snapshot_history( + before_document, + before_selection, + semantic_commands.unwrap_or_default(), + cx, + ); } - fn insert_blocks_after_caret_without_history(&mut self, blocks: Vec) { + fn insert_blocks_after_caret_without_history(&mut self, blocks: Vec, cx: &mut Context) -> Range { if blocks.is_empty() { - return; + return 0..0; } - let insert_ix = self.prepare_block_insertion_index(); + let remove_empty_caret_paragraph = !blocks.iter().any(|block| matches!(block, Block::Paragraph(_))); + let insert_ix = self.prepare_block_insertion_index(remove_empty_caret_paragraph, cx); let inserted_count = blocks.len(); Arc::make_mut(&mut self.document.blocks).splice(insert_ix..insert_ix, blocks); for relative_ix in 0..inserted_count { @@ -175,9 +191,10 @@ impl RichTextEditor { self.clear_layout_work_caches(); self.item_sizes_cache = None; self.paragraph_height_cache_revision = self.paragraph_height_cache_revision.wrapping_add(1); + insert_ix..insert_ix + inserted_count } - fn prepare_block_insertion_index(&mut self) -> usize { + fn prepare_block_insertion_index(&mut self, remove_empty_caret_paragraph: bool, cx: &mut Context) -> usize { if let Some( BlockSelection::Image(block_ix) | BlockSelection::Equation(block_ix) @@ -188,7 +205,9 @@ impl RichTextEditor { return (block_ix + 1).min(self.document.blocks.len()); } - if let Some(insert_ix) = self.remove_empty_caret_paragraph_for_block_insertion() { + if remove_empty_caret_paragraph + && let Some(insert_ix) = self.remove_empty_caret_paragraph_for_block_insertion(cx) + { return insert_ix; } @@ -220,7 +239,7 @@ impl RichTextEditor { self.document.blocks.len() } - fn remove_empty_caret_paragraph_for_block_insertion(&mut self) -> Option { + fn remove_empty_caret_paragraph_for_block_insertion(&mut self, cx: &mut Context) -> Option { if !self.selection.is_caret() { return None; } @@ -251,16 +270,11 @@ impl RichTextEditor { rebuild_document_offset_index(&mut self.document); rebuild_document_sections(&mut self.document); let new_paragraph_ix = paragraph_ix.min(self.document.paragraphs.len().saturating_sub(1)); - self.selection = EditorSelection { - anchor: DocumentOffset { - paragraph: new_paragraph_ix, - byte: 0, - }, - head: DocumentOffset { - paragraph: new_paragraph_ix, - byte: 0, - }, - }; + self.selection = EditorSelection::collapsed(DocumentOffset { + paragraph: new_paragraph_ix, + byte: 0, + }); + self.emit_selection_changed(cx); } Some(block_ix) } @@ -286,7 +300,133 @@ impl RichTextEditor { rebuild_document_sections(&mut self.document); } - fn push_replace_document_history(&mut self, before_document: Document, before_selection: EditorSelection, cx: &mut Context) { + fn insert_block_semantic_commands( + &self, + before_document: &DocumentProjection, + before_selection: &EditorSelection, + inserted_range: Range, + ) -> Option> { + if inserted_range.is_empty() { + return None; + } + + let inserted_blocks = self.document.blocks.get(inserted_range.clone())?; + let inserted_paragraph_count = inserted_blocks + .iter() + .filter(|block| matches!(block, Block::Paragraph(_))) + .count(); + let inserted_object_count = inserted_blocks.len() - inserted_paragraph_count; + let deleted_object_ids = if before_selection.is_caret() { + Vec::new() + } else { + object_block_ids_in_text_range(before_document, before_selection.normalized())? + }; + let mut commands = Vec::with_capacity( + deleted_object_ids.len() + usize::from(inserted_paragraph_count > 0 || !before_selection.is_caret()) + inserted_object_count, + ); + commands.extend( + deleted_object_ids + .into_iter() + .map(|block| SemanticEditCommand::DeleteBlock { block }), + ); + + if before_selection.is_caret() && inserted_paragraph_count > 0 { + let before_paragraph_count = before_document.paragraphs.len(); + if before_paragraph_count == 0 { + return None; + } + let insert_paragraph_ix = self + .document + .blocks + .iter() + .take(inserted_range.start) + .filter(|block| matches!(block, Block::Paragraph(_))) + .count(); + let span_start = insert_paragraph_ix.min(before_paragraph_count.saturating_sub(1)); + let before_span = if insert_paragraph_ix < before_paragraph_count { + capture_document_span(before_document, insert_paragraph_ix..insert_paragraph_ix + 1) + } else { + capture_document_span(before_document, span_start..span_start + 1) + }; + let after_count = inserted_paragraph_count + 1; + let after_span = capture_document_span(&self.document, span_start..span_start + after_count); + commands.push(SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: before_span.start_paragraph, + byte: 0, + }), + before: before_span, + after: after_span, + }); + } else if !before_selection.is_caret() { + let before_range = before_selection.normalized(); + let before_paragraph_count = before_document.paragraphs.len(); + if before_paragraph_count == 0 || before_range.start.paragraph >= before_paragraph_count { + return None; + } + let before_span = capture_document_span( + before_document, + before_range.start.paragraph..before_range.end.paragraph.saturating_add(1).min(before_paragraph_count), + ); + let paragraph_delta = self.document.paragraphs.len() as isize - before_paragraph_count as isize; + let after_count = before_span + .paragraphs + .len() + .saturating_add_signed(paragraph_delta) + .min( + self + .document + .paragraphs + .len() + .saturating_sub(before_span.start_paragraph), + ); + let after_span = capture_document_span(&self.document, before_span.start_paragraph..before_span.start_paragraph + after_count); + if before_span != after_span { + commands.push(SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: before_span.start_paragraph, + byte: 0, + }), + before: before_span, + after: after_span, + }); + } + } + + for block_ix in inserted_range { + if matches!(self.document.blocks.get(block_ix), Some(Block::Paragraph(_))) { + continue; + } + let block = self.document.ids.block_ids.get(block_ix).copied().or_else(|| { + eprintln!("skipping inserted object semantic command because projection block {block_ix} has no durable id"); + None + })?; + let after = self + .document + .blocks + .get(block_ix) + .map(input_block_from_block)?; + commands.push(SemanticEditCommand::InsertBlock { + block, + block_ix, + after, + }); + } + (!commands.is_empty()).then_some(commands) + } + + #[cfg(test)] + pub(super) fn insert_block_fragment_for_test(&mut self, fragment: RichClipboardFragment, cx: &mut Context) { + self.insert_block_fragment(fragment, cx); + } + + fn push_document_snapshot_history( + &mut self, + before_document: DocumentProjection, + before_selection: EditorSelection, + semantic_commands: Vec, + cx: &mut Context, + ) { if before_document.text == self.document.text && before_document.paragraphs == self.document.paragraphs && before_document.blocks == self.document.blocks @@ -297,20 +437,19 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + self.record_local_history(EditRecord { before_selection, before_generation, after_selection: self.selection.clone(), after_generation, - operations: vec![EditOperation::ReplaceDocument { + operations: vec![EditOperation::RestoreProjectionSnapshot { before: Box::new(before_document), after: Box::new(self.document.clone()), }], - canonical_operations: vec![CanonicalOperation::ReplaceDocument], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); } fn insert_plain_text_fragment(&mut self, text: &str, cx: &mut Context) { @@ -318,7 +457,10 @@ impl RichTextEditor { if normalized.is_empty() { return; } - let paragraph_style = self.document.paragraphs[self.selection.head.paragraph].style; + let Some(paragraph) = self.document.paragraphs.get(self.selection.head.paragraph) else { + return; + }; + let paragraph_style = paragraph.style; let styles = self.styles_at_caret(); let fragment = RichClipboardFragment { format: RICH_TEXT_CLIPBOARD_FORMAT.to_string(), @@ -350,3 +492,19 @@ fn non_empty_input_paragraphs(paragraphs: Vec) -> Vec) -> Option> { + let start_block = block_ix_for_paragraph(document, range.start.paragraph)?; + let end_block = block_ix_for_paragraph(document, range.end.paragraph)?; + let mut object_ids = Vec::new(); + for block_ix in (start_block + 1)..end_block { + if document + .blocks + .get(block_ix) + .is_some_and(|block| !matches!(block, Block::Paragraph(_))) + { + object_ids.push(*document.ids.block_ids.get(block_ix)?); + } + } + Some(object_ids) +} diff --git a/crates/gpui-flowtext/src/rich_text/editor/caret_movement.rs b/crates/gpui-flowtext/src/rich_text/editor/caret_movement.rs index 09d57050..69ac0f20 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/caret_movement.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/caret_movement.rs @@ -102,9 +102,15 @@ impl RichTextEditor { } }, }; - let anchor = if extend { self.selection.anchor } else { new_head }; - let selection = EditorSelection { anchor, head: new_head }; - if self.selection == selection { + // §16: a rightward step parks the caret after the preceding glyph, a + // leftward step before the following glyph. Gravity stays neutral — arrow + // motion keeps the historical wrap-seam bias. + let head_affinity = match dir { + HDir::Left => SelectionAffinity::Before, + HDir::Right => SelectionAffinity::After, + }; + let selection = self.selection.moved(new_head, head_affinity, VisualGravity::Neutral, extend); + if self.selection.same_positions(&selection) { self.goal_x = None; return; } @@ -115,6 +121,7 @@ impl RichTextEditor { let _ = self.paragraph_item_sizes(window, cx); self.scroll_head_into_view(); self.reset_caret_blink(cx); + self.emit_selection_changed(cx); cx.notify(); } @@ -164,7 +171,7 @@ impl RichTextEditor { let Some(layout) = self.layout_for_offset(head) else { return; }; - let Some((p_ix, l_ix)) = locate_line(&layout, head) else { + let Some((p_ix, l_ix)) = locate_line(&layout, head, self.selection.head_gravity) else { cx.notify(); return; }; @@ -187,9 +194,10 @@ impl RichTextEditor { }; (new_head, cur_x) }; - let anchor = if extend { self.selection.anchor } else { new_head }; - let selection = EditorSelection { anchor, head: new_head }; - if self.selection == selection { + // Vertical motion lands on a fresh column position; reset to neutral + // affinity/gravity. + let selection = self.selection.moved(new_head, SelectionAffinity::Neutral, VisualGravity::Neutral, extend); + if self.selection.same_positions(&selection) { self.goal_x = Some(used_goal_x); return; } @@ -199,6 +207,7 @@ impl RichTextEditor { self.goal_x = Some(used_goal_x); self.scroll_head_into_view(); self.reset_caret_blink(cx); + self.emit_selection_changed(cx); cx.notify(); } @@ -251,11 +260,11 @@ impl RichTextEditor { paragraph: target_paragraph, byte: target_byte, }; - let anchor = if extend { self.selection.anchor } else { new_head }; - self.selection = EditorSelection { anchor, head: new_head }; + self.selection = self.selection.moved(new_head, SelectionAffinity::Neutral, VisualGravity::Neutral, extend); self.goal_x = Some(goal_x); self.scroll_head_into_view(); self.reset_caret_blink(cx); + self.emit_selection_changed(cx); cx.notify(); } diff --git a/crates/gpui-flowtext/src/rich_text/editor/commands.rs b/crates/gpui-flowtext/src/rich_text/editor/commands.rs index 2e3a3026..e72c5375 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/commands.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/commands.rs @@ -159,18 +159,16 @@ impl RichTextEditor { if paragraph_ix < self.document.paragraphs.len() { // Outline navigation should place the insertion caret at the start of // the target paragraph, matching what the user just selected in the nav. - self.selection = EditorSelection { - anchor: DocumentOffset { - paragraph: paragraph_ix, - byte: 0, - }, - head: DocumentOffset { - paragraph: paragraph_ix, - byte: 0, - }, - }; + let before_selection = self.selection.clone(); + self.selection = EditorSelection::collapsed(DocumentOffset { + paragraph: paragraph_ix, + byte: 0, + }); self.goal_x = None; self.reset_caret_blink(cx); + if self.selection != before_selection { + self.emit_selection_changed(cx); + } let width = self.current_layout_width(); let start = paragraph_ix.saturating_sub(2); @@ -189,6 +187,41 @@ impl RichTextEditor { } pub fn undo(&mut self, cx: &mut Context) { + if let Some(hook) = self.native_undo_hook.clone() { + let pending_edits = self.take_pending_semantic_edits(); + let pending_edits_for_retry = pending_edits.clone(); + let assets = self.document.assets.assets.values().cloned().collect(); + let fallback_selection = self.selection.clone(); + let generation = self.next_edit_generation; + self.next_edit_generation = self.next_edit_generation.wrapping_add(1); + cx.spawn(async move |editor, cx| { + let result = hook(UndoRedirect::Undo, pending_edits, assets).await; + let _ = editor.update(cx, |editor, cx| match result { + Ok(Some(result)) => { + editor.document = result.document; + editor.identity_map.reconcile(&editor.document); + editor.selection = result.selection.unwrap_or(fallback_selection); + editor.emit_selection_changed(cx); + editor.edit_generation = generation; + editor.undo_stack.clear(); + editor.redo_stack.clear(); + editor.after_history_restore(cx); + }, + Ok(None) => {}, + Err(error) => { + editor.prepend_pending_semantic_edits(pending_edits_for_retry); + eprintln!("runtime undo failed: {error}"); + cx.notify(); + }, + }); + }) + .detach(); + return; + } + if let Some(hook) = self.session_undo_redirect.clone() { + hook(UndoRedirect::Undo); + return; + } let Some(record) = self.undo_stack.pop() else { return; }; @@ -197,12 +230,48 @@ impl RichTextEditor { operation.undo(&mut self.document); } self.selection = record.before_selection.clone(); + self.emit_selection_changed(cx); self.edit_generation = restored_generation; self.redo_stack.push(record); self.after_history_restore(cx); } pub fn redo(&mut self, cx: &mut Context) { + if let Some(hook) = self.native_undo_hook.clone() { + let pending_edits = self.take_pending_semantic_edits(); + let pending_edits_for_retry = pending_edits.clone(); + let assets = self.document.assets.assets.values().cloned().collect(); + let fallback_selection = self.selection.clone(); + let generation = self.next_edit_generation; + self.next_edit_generation = self.next_edit_generation.wrapping_add(1); + cx.spawn(async move |editor, cx| { + let result = hook(UndoRedirect::Redo, pending_edits, assets).await; + let _ = editor.update(cx, |editor, cx| match result { + Ok(Some(result)) => { + editor.document = result.document; + editor.identity_map.reconcile(&editor.document); + editor.selection = result.selection.unwrap_or(fallback_selection); + editor.emit_selection_changed(cx); + editor.edit_generation = generation; + editor.undo_stack.clear(); + editor.redo_stack.clear(); + editor.after_history_restore(cx); + }, + Ok(None) => {}, + Err(error) => { + editor.prepend_pending_semantic_edits(pending_edits_for_retry); + eprintln!("runtime redo failed: {error}"); + cx.notify(); + }, + }); + }) + .detach(); + return; + } + if let Some(hook) = self.session_undo_redirect.clone() { + hook(UndoRedirect::Redo); + return; + } let Some(record) = self.redo_stack.pop() else { return; }; @@ -211,17 +280,18 @@ impl RichTextEditor { operation.redo(&mut self.document); } self.selection = record.after_selection.clone(); + self.emit_selection_changed(cx); self.edit_generation = restored_generation; self.undo_stack.push(record); self.after_history_restore(cx); } pub fn can_undo(&self) -> bool { - !self.undo_stack.is_empty() + self.native_undo_hook.is_some() || self.session_undo_redirect.is_some() || !self.undo_stack.is_empty() } pub fn can_redo(&self) -> bool { - !self.redo_stack.is_empty() + self.native_undo_hook.is_some() || self.session_undo_redirect.is_some() || !self.redo_stack.is_empty() } pub fn move_left(&mut self, window: &mut Window, cx: &mut Context) { @@ -288,13 +358,13 @@ impl RichTextEditor { } let last = self.document.paragraphs.len() - 1; let last_len = paragraph_text_len(&self.document.paragraphs[last]); - let selection = EditorSelection { - anchor: DocumentOffset { paragraph: 0, byte: 0 }, - head: DocumentOffset { + let selection = EditorSelection::range( + DocumentOffset { paragraph: 0, byte: 0 }, + DocumentOffset { paragraph: last, byte: last_len, }, - }; + ); if self.selection == selection { self.goal_x = None; return; @@ -302,23 +372,24 @@ impl RichTextEditor { self.selection = selection; self.goal_x = None; self.reset_caret_blink(cx); + self.emit_selection_changed(cx); cx.notify(); } pub fn move_word_left(&mut self, cx: &mut Context) { - self.move_to_offset(self.word_left(self.selection.head), false, cx); + self.move_to_offset(self.word_left(self.selection.head), SelectionAffinity::Before, VisualGravity::Neutral, false, cx); } pub fn move_word_right(&mut self, cx: &mut Context) { - self.move_to_offset(self.word_right(self.selection.head), false, cx); + self.move_to_offset(self.word_right(self.selection.head), SelectionAffinity::After, VisualGravity::Neutral, false, cx); } pub fn select_word_left(&mut self, cx: &mut Context) { - self.move_to_offset(self.word_left(self.selection.head), true, cx); + self.move_to_offset(self.word_left(self.selection.head), SelectionAffinity::Before, VisualGravity::Neutral, true, cx); } pub fn select_word_right(&mut self, cx: &mut Context) { - self.move_to_offset(self.word_right(self.selection.head), true, cx); + self.move_to_offset(self.word_right(self.selection.head), SelectionAffinity::After, VisualGravity::Neutral, true, cx); } pub fn page_up(&mut self, cx: &mut Context) { @@ -338,19 +409,19 @@ impl RichTextEditor { } pub fn move_document_start(&mut self, cx: &mut Context) { - self.move_to_offset(DocumentOffset::default(), false, cx); + self.move_to_offset(DocumentOffset::default(), SelectionAffinity::Before, VisualGravity::Neutral, false, cx); } pub fn move_document_end(&mut self, cx: &mut Context) { - self.move_to_offset(document_end(&self.document), false, cx); + self.move_to_offset(document_end(&self.document), SelectionAffinity::After, VisualGravity::Neutral, false, cx); } pub fn select_document_start(&mut self, cx: &mut Context) { - self.move_to_offset(DocumentOffset::default(), true, cx); + self.move_to_offset(DocumentOffset::default(), SelectionAffinity::Before, VisualGravity::Neutral, true, cx); } pub fn select_document_end(&mut self, cx: &mut Context) { - self.move_to_offset(document_end(&self.document), true, cx); + self.move_to_offset(document_end(&self.document), SelectionAffinity::After, VisualGravity::Neutral, true, cx); } pub fn insert_text_command(&mut self, text: &str, cx: &mut Context) { @@ -358,6 +429,11 @@ impl RichTextEditor { cx.notify(); return; } + let before = self.selection.clone(); + clamp_selection_to_document(&self.document, &mut self.selection); + if self.selection != before { + self.emit_selection_changed(cx); + } if self.insert_single_grapheme_fast_path(text, cx) { return; } @@ -407,7 +483,7 @@ impl RichTextEditor { if editor.selection.is_caret() { let head = editor.selection.head; let anchor = editor.word_left(head); - editor.selection = EditorSelection { anchor, head }; + editor.selection = EditorSelection::range(anchor, head); } editor.delete_selection_internal(); editor.after_text_mutation(cx); @@ -419,7 +495,7 @@ impl RichTextEditor { if editor.selection.is_caret() { let anchor = editor.selection.head; let head = editor.word_right(anchor); - editor.selection = EditorSelection { anchor, head }; + editor.selection = EditorSelection::range(anchor, head); } editor.delete_selection_internal(); editor.after_text_mutation(cx); @@ -568,6 +644,9 @@ impl RichTextEditor { if text.trim().is_empty() { return; } + if self.insert_text_into_selected_equation(text, cx) { + return; + } if self.insert_plain_text_into_selected_table_cell(text, cx) { return; } diff --git a/crates/gpui-flowtext/src/rich_text/editor/drop_preview.rs b/crates/gpui-flowtext/src/rich_text/editor/drop_preview.rs index 9c32d8a6..f88a56e2 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/drop_preview.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/drop_preview.rs @@ -6,7 +6,7 @@ enum DropPreviewKind { #[derive(Clone)] enum DropPreviewContent { - Document(Box), + DocumentProjection(Box), ExternalPaths { label: SharedString }, } @@ -88,7 +88,7 @@ impl RichTextEditor { && preview.fingerprint == fingerprint && preview.is_first == is_first && preview.is_last == is_last - && matches!(&preview.content, DropPreviewContent::Document(_)) + && matches!(&preview.content, DropPreviewContent::DocumentProjection(_)) { preview.insert_block_ix = placement.insert_block_ix; preview.suppressed_block_ix = placement.suppressed_block_ix; @@ -107,7 +107,7 @@ impl RichTextEditor { is_last, width, height, - content: DropPreviewContent::Document(Box::new(document)), + content: DropPreviewContent::DocumentProjection(Box::new(document)), }); } @@ -182,7 +182,7 @@ impl RichTextEditor { } } - fn drop_preview_document_from_paragraphs(&self, paragraphs: Vec, is_first: bool, is_last: bool) -> Document { + fn drop_preview_document_from_paragraphs(&self, paragraphs: Vec, is_first: bool, is_last: bool) -> DocumentProjection { let mut theme = self.document.theme.clone(); if !is_first { theme.pageless_inset_top = px(0.0); @@ -214,16 +214,16 @@ impl RichTextEditor { cx: &mut Context, ) -> (RenderVirtualItems, Rc>>) { if self.drop_preview.is_none() { - return (RenderVirtualItems::Document(base_items), base_sizes); + return (RenderVirtualItems::DocumentProjection(base_items), base_sizes); } self.refresh_drop_preview_height(width, window, cx); let Some(preview) = &self.drop_preview else { - return (RenderVirtualItems::Document(base_items), base_sizes); + return (RenderVirtualItems::DocumentProjection(base_items), base_sizes); }; let Some(cache) = self.item_sizes_cache.as_ref() else { - return (RenderVirtualItems::Document(base_items), base_sizes); + return (RenderVirtualItems::DocumentProjection(base_items), base_sizes); }; let insert_item_ix = drop_preview_insert_item_ix(cache, preview.insert_block_ix); @@ -244,7 +244,7 @@ impl RichTextEditor { if suppressed_range.contains(&item_ix) { continue; } - items.push(RenderVirtualItem::Document(base_items[item_ix].clone())); + items.push(RenderVirtualItem::DocumentProjection(base_items[item_ix].clone())); sizes.push(base_sizes[item_ix]); } @@ -260,7 +260,7 @@ impl RichTextEditor { } preview.width = width; preview.height = match &preview.content { - DropPreviewContent::Document(document) => drop_preview_document_height(document, width, self.invisibility_mode, window, cx), + DropPreviewContent::DocumentProjection(document) => drop_preview_document_height(document, width, self.invisibility_mode, window, cx), DropPreviewContent::ExternalPaths { .. } => drop_preview_external_paths_height(width, window, cx), }; } @@ -268,13 +268,13 @@ impl RichTextEditor { fn render_drop_preview(preview: DropPreview, invisibility_mode: bool, cx: &mut Context) -> impl IntoElement { let background = match &preview.content { - DropPreviewContent::Document(document) => document.theme.document_background_color.opacity(0.78), + DropPreviewContent::DocumentProjection(document) => document.theme.document_background_color.opacity(0.78), DropPreviewContent::ExternalPaths { .. } => cx.theme().background.opacity(0.78), }; let border = cx.theme().drag_border.opacity(0.72); let text_color = cx.theme().foreground.opacity(0.72); let content = match preview.content { - DropPreviewContent::Document(document) => div() + DropPreviewContent::DocumentProjection(document) => div() .w_full() .child(RichTextDocumentElement::new(*document).with_invisibility_mode(invisibility_mode)) .into_any_element(), @@ -312,7 +312,7 @@ fn drop_preview_insert_item_ix(cache: &ItemSizesCache, insert_block_ix: usize) - } fn drop_preview_document_height( - document: &Document, + document: &DocumentProjection, width: Pixels, invisibility_mode: bool, window: &mut Window, diff --git a/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs b/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs index 7c0bda2a..68c5843b 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs @@ -1,6 +1,7 @@ #[hotpath::measure_all] impl RichTextEditor { - fn insert_single_grapheme_fast_path(&mut self, text: &str, cx: &mut Context) -> bool { + + pub(super) fn insert_single_grapheme_fast_path(&mut self, text: &str, cx: &mut Context) -> bool { if !is_single_grapheme_text_insert(text) || !self.selection.is_caret() || self.selected_block.is_some() { return false; } @@ -14,10 +15,6 @@ impl RichTextEditor { if caret.byte > paragraph_text_len(paragraph) { return false; } - let Some(paragraph_id) = self.identity_map.paragraph_id(caret.paragraph) else { - return false; - }; - let before_selection = self.selection.clone(); let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; @@ -25,7 +22,7 @@ impl RichTextEditor { let styles = if let Some(styles) = self.pending_styles { styles } else { - let (run_ix, _) = run_containing(paragraph, caret.byte); + let (run_ix, _) = run_containing(paragraph, caret.byte.min(paragraph_text_len(paragraph))); paragraph .runs .get(run_ix) @@ -33,71 +30,81 @@ impl RichTextEditor { .unwrap_or_default() }; - insert_text_at(&mut self.document, caret.paragraph, caret.byte, text, styles); let after = DocumentOffset { paragraph: caret.paragraph, byte: caret.byte + text.len(), }; - self.selection = EditorSelection { anchor: after, head: after }; + insert_text_at(&mut self.document, caret.paragraph, caret.byte, text, styles); + self.selection = EditorSelection::collapsed(after); + self.emit_selection_changed(cx); - let mut merged_into_previous = false; - if let Some(record) = self.undo_stack.last_mut() - && before_selection.anchor == before_selection.head - && record.after_selection == before_selection - && record.operations.len() == 1 - && record.canonical_operations.len() == 1 - && let EditOperation::InsertText { - paragraph, - byte, - text: previous_text, - styles: previous_styles, - } = &mut record.operations[0] - && *paragraph == caret.paragraph - && *previous_styles == styles - && *byte + previous_text.len() == caret.byte - && let CanonicalOperation::InsertText { - paragraph: canonical_paragraph, - byte: canonical_byte, - text: canonical_text, - styles: canonical_styles, - } = &mut record.canonical_operations[0] - && *canonical_paragraph == paragraph_id - && *canonical_styles == styles - && *canonical_byte + canonical_text.len() == caret.byte - { - previous_text.push_str(text); - canonical_text.push_str(text); - record.after_selection = self.selection.clone(); - record.after_generation = after_generation; - merged_into_previous = true; - } + if self.local_history_enabled() { + let mut merged_into_previous = false; + if let Some(record) = self.undo_stack.last_mut() + && before_selection.anchor == before_selection.head + && record.after_selection == before_selection + && record.operations.len() == 1 + && record.semantic_commands.len() == 1 + && let EditOperation::InsertText { + paragraph, + byte, + text: previous_text, + styles: previous_styles, + } = &mut record.operations[0] + && *paragraph == caret.paragraph + && *previous_styles == styles + && *byte + previous_text.len() == caret.byte + && let SemanticEditCommand::InsertText { + at, + text: semantic_text, + styles: semantic_styles, + } = &mut record.semantic_commands[0] + && at.paragraph == caret.paragraph + && *semantic_styles == styles + && at.byte + semantic_text.len() == caret.byte + { + previous_text.push_str(text); + semantic_text.push_str(text); + record.after_selection = self.selection.clone(); + record.after_generation = after_generation; + merged_into_previous = true; + } - if !merged_into_previous { - self.undo_stack.push(EditRecord { - before_selection, - before_generation, - after_selection: self.selection.clone(), - after_generation, - operations: vec![EditOperation::InsertText { - paragraph: caret.paragraph, - byte: caret.byte, - text: text.to_string(), - styles, - }], - canonical_operations: vec![CanonicalOperation::InsertText { - paragraph: paragraph_id, - byte: caret.byte, - text: text.to_string(), - styles, - }], - }); + if !merged_into_previous { + self.undo_stack.push(EditRecord { + before_selection, + before_generation, + after_selection: self.selection.clone(), + after_generation, + operations: vec![EditOperation::InsertText { + paragraph: caret.paragraph, + byte: caret.byte, + text: text.to_string(), + styles, + }], + semantic_commands: vec![SemanticEditCommand::InsertText { + at: caret, + text: text.to_string(), + styles, + }], + }); + } + self.redo_stack.clear(); } - self.redo_stack.clear(); + self.capture_semantic_edit(SemanticCommandBatch { + base_frontier: self.document.frontier.clone(), + semantic_commands: vec![SemanticEditCommand::InsertText { + at: caret, + text: text.to_string(), + styles, + }], + selection_after: Some(self.selection.clone()), + }); self.layout_invalidation_hint = Some(caret.paragraph..caret.paragraph + 1); self.suppress_mutation_notify += 1; self.after_text_mutation(cx); self.suppress_mutation_notify = self.suppress_mutation_notify.saturating_sub(1); - self.mark_document_changed_with_reconcile(after_generation, false, cx); + self.mark_document_changed_with_ops(after_generation, false, None, cx); true } @@ -164,11 +171,7 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - let canonical_operations = vec![CanonicalOperation::ReplaceParagraphSpan { - start_paragraph: self.identity_map.paragraph_id(before_span.start_paragraph), - before: before_span.clone(), - after: after_span.clone(), - }]; + let semantic_commands = self.semantic_commands_for_span_edit(&before_span, &after_span); let identity_shape_changed = before_span.paragraphs.len() != after_span.paragraphs.len() || before_block_count != self.document.blocks.len(); let record = EditRecord { before_selection, @@ -179,14 +182,99 @@ impl RichTextEditor { before: before_span, after: after_span, }], - canonical_operations, + semantic_commands: semantic_commands.clone(), }; - self.undo_stack.push(record); - self.redo_stack.clear(); - self.mark_document_changed_with_reconcile(after_generation, identity_shape_changed, cx); + self.record_local_history(record); + self.mark_document_changed_with_ops(after_generation, identity_shape_changed, Some(&semantic_commands), cx); } - fn insert_paragraph_break_at_caret(&mut self, caret: DocumentOffset, block_ix: usize, cx: &mut Context) { + fn semantic_commands_for_span_edit(&self, before: &DocumentSpan, after: &DocumentSpan) -> Vec { + if before.paragraphs.len() != after.paragraphs.len() || before.start_paragraph != after.start_paragraph { + return vec![SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: before.start_paragraph, + byte: 0, + }), + before: before.clone(), + after: after.clone(), + }]; + } + + let before_texts = span_paragraph_texts_for_commands(before); + let after_texts = span_paragraph_texts_for_commands(after); + let mut commands = Vec::new(); + for paragraph_offset in 0..before.paragraphs.len() { + let paragraph_ix = before.start_paragraph + paragraph_offset; + let Some(paragraph_id) = self.identity_map.paragraph_id(paragraph_ix) else { + return vec![SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: before.start_paragraph, + byte: 0, + }), + before: before.clone(), + after: after.clone(), + }]; + }; + let before_paragraph = &before.paragraphs[paragraph_offset]; + let after_paragraph = &after.paragraphs[paragraph_offset]; + let before_text = &before_texts[paragraph_offset]; + let after_text = &after_texts[paragraph_offset]; + + if before_text != after_text { + let prefix = common_prefix_bytes(before_text, after_text); + let suffix = common_suffix_bytes(before_text, after_text, prefix); + let before_end = before_text.len().saturating_sub(suffix); + let after_end = after_text.len().saturating_sub(suffix); + if before_end > prefix { + commands.push(SemanticEditCommand::DeleteRange { + range: DocumentOffset { + paragraph: paragraph_ix, + byte: prefix, + }..DocumentOffset { + paragraph: paragraph_ix, + byte: before_end, + }, + }); + } + let mut inserted = 0usize; + for (text, styles) in input_segments_for_range(after_paragraph, after_text, prefix..after_end) { + commands.push(SemanticEditCommand::InsertText { + at: DocumentOffset { + paragraph: paragraph_ix, + byte: prefix + inserted, + }, + text: text.clone(), + styles, + }); + inserted += text.len(); + } + } + + if before_paragraph.style != after_paragraph.style { + commands.push(SemanticEditCommand::SetParagraphStyle { + paragraph: paragraph_id, + style: after_paragraph.style, + }); + } + if before_paragraph.runs != after_paragraph.runs { + let mut byte = 0usize; + for run in &after_paragraph.runs { + let end = byte.saturating_add(run.len).min(after_text.len()); + if end > byte { + commands.push(SemanticEditCommand::SetRunStyles { + paragraph: paragraph_id, + range: byte..end, + styles: run.styles, + }); + } + byte = end; + } + } + } + commands + } + + fn insert_paragraph_break_at_caret(&mut self, caret: DocumentOffset, _block_ix: usize, cx: &mut Context) { let before_selection = self.selection.clone(); let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; @@ -197,15 +285,28 @@ impl RichTextEditor { self.insert_paragraph_break(cx); self.suppress_mutation_notify = self.suppress_mutation_notify.saturating_sub(1); self.layout_invalidation_hint = None; - self - .identity_map - .insert_split_paragraph(caret.paragraph, block_ix); + self.identity_map.reconcile(&self.document); let after_span = capture_document_span(&self.document, caret.paragraph..caret.paragraph + 2); if before_span == after_span && before_selection == self.selection { return; } + let inherited_style = before_span + .paragraphs + .first() + .map(|paragraph| { + if caret.byte >= paragraph_text_len(paragraph) { + ParagraphStyle::Normal + } else { + paragraph.style + } + }) + .unwrap_or(ParagraphStyle::Normal); + let semantic_commands = vec![SemanticEditCommand::SplitParagraph { + at: caret, + inherited_style, + }]; let record = EditRecord { before_selection, before_generation, @@ -215,29 +316,32 @@ impl RichTextEditor { before: before_span.clone(), after: after_span.clone(), }], - canonical_operations: vec![CanonicalOperation::ReplaceParagraphSpan { - start_paragraph: self.identity_map.paragraph_id(caret.paragraph), - before: before_span, - after: after_span, - }], + semantic_commands: semantic_commands.clone(), }; - self.undo_stack.push(record); - self.redo_stack.clear(); - self.mark_document_changed_with_reconcile(after_generation, false, cx); + self.record_local_history(record); + self.mark_document_changed_with_ops(after_generation, false, Some(&semantic_commands), cx); } - fn mark_document_changed(&mut self, generation: u64, cx: &mut Context) { - self.mark_document_changed_with_reconcile(generation, true, cx); - } + fn mark_document_changed_with_ops( + &mut self, + generation: u64, + reconcile_identity: bool, + semantic_commands: Option<&[SemanticEditCommand]>, + cx: &mut Context, + ) { + let runtime_owned = semantic_commands.is_some_and(|commands| !commands.is_empty()) && self.command_capture_enabled(); + if runtime_owned { + self.capture_semantic_edit(SemanticCommandBatch { + base_frontier: self.document.frontier.clone(), + semantic_commands: semantic_commands.unwrap_or_default().to_vec(), + selection_after: Some(self.selection.clone()), + }); + } - fn mark_document_changed_with_reconcile(&mut self, generation: u64, reconcile_identity: bool, cx: &mut Context) { self.edit_generation = generation; if reconcile_identity { self.identity_map.reconcile(&self.document); } - self.last_collaboration_edit = self.undo_stack.last().map(|record| CollaborationEdit { - operations: record.canonical_operations.clone(), - }); self.refresh_save_status(); self.schedule_recovery_write(cx); cx.notify(); @@ -260,3 +364,76 @@ impl RichTextEditor { } } + +fn span_paragraph_texts_for_commands(span: &DocumentSpan) -> Vec { + let mut offset = 0usize; + span + .paragraphs + .iter() + .enumerate() + .map(|(paragraph_ix, paragraph)| { + if paragraph_ix > 0 && span.text.get(offset..).is_some_and(|text| text.starts_with('\n')) { + offset += 1; + } + let len = paragraph_text_len(paragraph); + let end = offset.saturating_add(len).min(span.text.len()); + let text = span.text.get(offset..end).unwrap_or_default().to_string(); + offset = end; + text + }) + .collect() +} + +fn common_prefix_bytes(left: &str, right: &str) -> usize { + let mut len = 0usize; + for ((left_ix, left_ch), (_, right_ch)) in left.char_indices().zip(right.char_indices()) { + if left_ch != right_ch { + break; + } + len = left_ix + left_ch.len_utf8(); + } + len +} + +fn common_suffix_bytes(left: &str, right: &str, prefix: usize) -> usize { + let mut len = 0usize; + for ((left_ix, left_ch), (right_ix, right_ch)) in left.char_indices().rev().zip(right.char_indices().rev()) { + if left_ix < prefix || right_ix < prefix || left_ch != right_ch { + break; + } + len += left_ch.len_utf8(); + } + len +} + +fn input_segments_for_range( + paragraph: &Paragraph, + text: &str, + range: Range, +) -> Vec<(String, RunStyles)> { + if range.is_empty() { + return Vec::new(); + } + let mut segments = Vec::new(); + let mut byte = 0usize; + for run in ¶graph.runs { + let run_start = byte; + let run_end = byte.saturating_add(run.len).min(text.len()); + byte = run_end; + let start = run_start.max(range.start); + let end = run_end.min(range.end); + if start < end + && let Some(segment) = text.get(start..end) + && !segment.is_empty() + { + segments.push((segment.to_string(), run.styles)); + } + } + if segments.is_empty() + && let Some(segment) = text.get(range) + && !segment.is_empty() + { + segments.push((segment.to_string(), RunStyles::default())); + } + segments +} diff --git a/crates/gpui-flowtext/src/rich_text/editor/formatting.rs b/crates/gpui-flowtext/src/rich_text/editor/formatting.rs index cbbd348c..78a4fbbd 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/formatting.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/formatting.rs @@ -130,10 +130,7 @@ impl RichTextEditor { blocks: Vec::new(), assets: Vec::new(), }; - self.selection = EditorSelection { - anchor: range.start, - head: range.end, - }; + self.selection = EditorSelection::range(range.start, range.end); self.apply_document_edit(cx, |editor, cx| editor.insert_rich_fragment(fragment, cx)); } @@ -368,7 +365,7 @@ impl RichTextEditor { } fn apply_highlight_to_existing_highlights_in_paragraph_range( - document: &mut Document, + document: &mut DocumentProjection, paragraph_ix: usize, range: Range, highlight: HighlightStyle, @@ -390,7 +387,7 @@ fn apply_highlight_to_existing_highlights_in_paragraph_range( ); } -fn hierarchical_section_bounds(document: &Document, paragraph_ix: usize, section_slots: &[u8]) -> Option<(usize, usize)> { +fn hierarchical_section_bounds(document: &DocumentProjection, paragraph_ix: usize, section_slots: &[u8]) -> Option<(usize, usize)> { let paragraph = document.paragraphs.get(paragraph_ix)?; let ParagraphStyle::Custom(slot) = paragraph.style else { return None; @@ -422,7 +419,7 @@ fn hierarchical_section_bounds(document: &Document, paragraph_ix: usize, section Some((paragraph_ix, end)) } -fn collapse_heading_kind(document: &Document, paragraph_ix: usize, section_slots: &[u8]) -> Option { +fn collapse_heading_kind(document: &DocumentProjection, paragraph_ix: usize, section_slots: &[u8]) -> Option { let paragraph = document.paragraphs.get(paragraph_ix)?; let ParagraphStyle::Custom(slot) = paragraph.style else { return None; @@ -436,14 +433,14 @@ fn collapse_heading_kind(document: &Document, paragraph_ix: usize, section_slots Some(style.section_kind.unwrap_or(slot)) } -fn section_at_heading(document: &Document, paragraph_ix: usize, heading_kind: u8) -> Option<&DocumentSection> { +fn section_at_heading(document: &DocumentProjection, paragraph_ix: usize, heading_kind: u8) -> Option<&DocumentSection> { document.sections.iter().find(|section| { let SectionKind::Custom(section_kind) = section.kind; section_kind == heading_kind && paragraph_index_for_id(document, section.start_paragraph) == Some(paragraph_ix) }) } -fn enclosing_section<'a>(document: &'a Document, paragraph_ix: usize, section_slots: &[u8]) -> Option<&'a DocumentSection> { +fn enclosing_section<'a>(document: &'a DocumentProjection, paragraph_ix: usize, section_slots: &[u8]) -> Option<&'a DocumentSection> { document .sections .iter() @@ -471,7 +468,7 @@ fn enclosing_section<'a>(document: &'a Document, paragraph_ix: usize, section_sl }) } -fn enclosing_section_bounds(document: &Document, paragraph_ix: usize, section_slots: &[u8]) -> Option<(usize, usize)> { +fn enclosing_section_bounds(document: &DocumentProjection, paragraph_ix: usize, section_slots: &[u8]) -> Option<(usize, usize)> { let section = enclosing_section(document, paragraph_ix, section_slots)?; let start = paragraph_index_for_id(document, section.start_paragraph)?; let end = section diff --git a/crates/gpui-flowtext/src/rich_text/editor/hit_testing.rs b/crates/gpui-flowtext/src/rich_text/editor/hit_testing.rs index f14b9fdf..00526b89 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/hit_testing.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/hit_testing.rs @@ -90,7 +90,9 @@ impl RichTextEditor { let Some(layout) = self.layout_for_offset(head) else { return; }; - let Some((p_ix, l_ix)) = locate_line(&layout, head) else { + // Resolve the caret's current visual line using its stored gravity so the + // line edges we snap to match where the caret is actually painted. + let Some((p_ix, l_ix)) = locate_line(&layout, head, self.selection.head_gravity) else { return; }; let line = &layout.paragraphs[p_ix].lines[l_ix]; @@ -100,9 +102,17 @@ impl RichTextEditor { paragraph: head.paragraph, byte: new_byte, }; - let anchor = if extend { self.selection.anchor } else { new }; - let selection = EditorSelection { anchor, head: new }; - if self.selection == selection { + // §16: line-start gravitates downstream (start of the lower visual line), + // line-end gravitates upstream (end of the upper visual line). This is the + // canonical wrap-seam case — without Upstream gravity, "End" on a wrapped + // line would otherwise paint at the start of the next visual line. + let (affinity, gravity) = if start { + (SelectionAffinity::Before, VisualGravity::Downstream) + } else { + (SelectionAffinity::After, VisualGravity::Upstream) + }; + let selection = self.selection.moved(new, affinity, gravity, extend); + if self.selection.same_positions(&selection) { self.goal_x = None; return; } @@ -110,6 +120,7 @@ impl RichTextEditor { self.goal_x = None; self.scroll_head_into_view(); self.reset_caret_blink(cx); + self.emit_selection_changed(cx); cx.notify(); } @@ -155,7 +166,7 @@ impl RichTextEditor { paragraph: caret.paragraph, byte: caret.byte + text.len(), }; - self.selection = EditorSelection { anchor: new, head: new }; + self.selection = EditorSelection::collapsed(new); self.after_text_mutation(cx); } @@ -174,10 +185,7 @@ impl RichTextEditor { // paragraphs are dropped wholesale. delete_cross_paragraph_range(&mut self.document, range.clone()); } - self.selection = EditorSelection { - anchor: range.start, - head: range.start, - }; + self.selection = EditorSelection::collapsed(range.start); true } @@ -220,7 +228,7 @@ impl RichTextEditor { paragraph: prev_ix, byte: prev_len, }; - self.selection = EditorSelection { anchor: new, head: new }; + self.selection = EditorSelection::collapsed(new); } else { let prev = prev_grapheme_boundary_in_paragraph(&self.document, caret.paragraph, caret.byte); delete_range_in_paragraph(&mut self.document, caret.paragraph, prev..caret.byte); @@ -228,7 +236,7 @@ impl RichTextEditor { paragraph: caret.paragraph, byte: prev, }; - self.selection = EditorSelection { anchor: new, head: new }; + self.selection = EditorSelection::collapsed(new); } self.after_text_mutation(cx); } @@ -287,7 +295,7 @@ impl RichTextEditor { clear_whole_paragraph_formatting(&mut self.document, new.paragraph); rebuild_document_sections(&mut self.document); } - self.selection = EditorSelection { anchor: new, head: new }; + self.selection = EditorSelection::collapsed(new); self.after_text_mutation(cx); } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs b/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs index bc5d7880..6e131862 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs @@ -239,10 +239,14 @@ impl RichTextEditor { .map(|paragraph| DocumentOffset { paragraph, byte: 0 }), }; if let Some(offset) = offset { - self.selection = EditorSelection { - anchor: offset, - head: offset, + // §16 object boundary: collapsing leftward lands after the preceding + // paragraph's content (After); rightward lands before the following + // paragraph's content (Before). + let affinity = match dir { + HDir::Left => SelectionAffinity::After, + HDir::Right => SelectionAffinity::Before, }; + self.selection = EditorSelection::collapsed_with(offset, affinity, VisualGravity::Neutral); self.scroll_head_into_view(); self.reset_caret_blink(cx); cx.notify(); diff --git a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs index 315f3d34..2e7ef3ad 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs @@ -1,3 +1,9 @@ +// Projection snapshots carry semantic style slots; their appearance catalog is editor-local. +fn projection_with_local_theme(mut document: DocumentProjection, theme: &DocumentTheme) -> DocumentProjection { + document.theme = theme.clone(); + document +} + #[hotpath::measure_all] impl RichTextEditor { pub fn clear_document_equation_caches(&self) { @@ -8,7 +14,7 @@ impl RichTextEditor { EquationRenderer::clear_entries(keys); } - pub fn new_with_path(mut document: Document, document_path: Option, cx: &mut Context) -> Self { + pub fn new_with_path(mut document: DocumentProjection, document_path: Option, cx: &mut Context) -> Self { rebuild_document_sections(&mut document); let paragraph_count = document.paragraphs.len(); let saved_generation = if document_path.is_some() { 0 } else { u64::MAX }; @@ -37,8 +43,17 @@ impl RichTextEditor { undo_stack: Vec::new(), redo_stack: Vec::new(), identity_map, - last_collaboration_edit: None, + pending_semantic_edits: Vec::new(), + runtime_edits_in_flight: 0, + command_capture_route: CommandCaptureRoute::Disabled, + native_save_hook: None, + native_export_hook: None, + native_undo_hook: None, + native_recovery_hook: None, + suppress_command_capture: 0, + session_undo_redirect: None, collaboration_role: None, + own_collaboration_caret_color_rgb: None, recovery_write_in_progress: false, recovery_write_pending: false, last_recovery_generation: 0, @@ -71,6 +86,7 @@ impl RichTextEditor { search_highlights: Vec::new(), active_search_highlight: None, last_text_input_at: None, + ime_marked_range: None, pending_typing_prefetch_resume: false, resume_chunk_prefetch_after_typing: false, paragraph_chunk_layout_cache: vec![None; paragraph_count], @@ -110,10 +126,16 @@ impl RichTextEditor { } } - pub fn document(&self) -> &Document { + pub fn document(&self) -> &DocumentProjection { &self.document } + fn emit_selection_changed(&self, cx: &mut Context) { + cx.emit(EditorEvent::SelectionChanged { + selection: self.selection.clone(), + }); + } + pub fn dispose_for_close(&mut self) { if self.disposed { return; @@ -145,8 +167,17 @@ impl RichTextEditor { fn release_transient_memory(&mut self) { self.undo_stack = Vec::new(); self.redo_stack = Vec::new(); - self.last_collaboration_edit = None; + self.pending_semantic_edits.clear(); + self.runtime_edits_in_flight = 0; + self.command_capture_route = CommandCaptureRoute::Disabled; + self.native_save_hook = None; + self.native_export_hook = None; + self.native_undo_hook = None; + self.native_recovery_hook = None; + self.suppress_command_capture = 0; + self.session_undo_redirect = None; self.collaboration_role = None; + self.own_collaboration_caret_color_rgb = None; self.recovery_write_in_progress = false; self.recovery_write_pending = false; self.paste_cache = None; @@ -176,6 +207,7 @@ impl RichTextEditor { self.caret_blink_active = false; self.external_carets.clear(); self.last_text_input_at = None; + self.ime_marked_range = None; self.pending_typing_prefetch_resume = false; self.resume_chunk_prefetch_after_typing = false; self.paragraph_chunk_layout_cache = Vec::new(); @@ -211,27 +243,250 @@ impl RichTextEditor { self.goal_x = None; } - pub fn last_collaboration_edit(&self) -> Option<&CollaborationEdit> { - self.last_collaboration_edit.as_ref() + pub fn take_pending_session_edits(&mut self) -> Vec { + if self.command_capture_route.accepts_collaboration() { + std::mem::take(&mut self.pending_semantic_edits) + } else { + Vec::new() + } } - pub fn last_collaboration_operations(&self) -> Option<&[CanonicalOperation]> { - self - .last_collaboration_edit - .as_ref() - .map(|edit| edit.operations.as_slice()) + pub fn take_pending_runtime_edits(&mut self) -> Vec { + if self.command_capture_route.accepts_runtime() { + std::mem::take(&mut self.pending_semantic_edits) + } else { + Vec::new() + } } - pub fn last_collaboration_operation_bytes(&self) -> Option> { - self - .last_collaboration_edit - .as_ref() - .and_then(|edit| crate::encode_canonical_operations(&edit.operations)) + pub fn take_pending_semantic_edits(&mut self) -> Vec { + std::mem::take(&mut self.pending_semantic_edits) + } + + pub fn prepend_pending_semantic_edits(&mut self, mut edits: Vec) { + if edits.is_empty() { + return; + } + edits.extend(std::mem::take(&mut self.pending_semantic_edits)); + self.pending_semantic_edits = edits; + } + + pub fn complete_runtime_edit(&mut self, selection: Option, cx: &mut Context) { + self.runtime_edits_in_flight = self.runtime_edits_in_flight.saturating_sub(1); + if self.runtime_edits_in_flight == 0 && self.pending_semantic_edits.is_empty() { + if let Some(selection) = selection { + self.selection = selection; + clamp_selection_to_document(&self.document, &mut self.selection); + self.emit_selection_changed(cx); + } + self.scroll_head_into_view(); + self.reset_caret_blink(cx); + } + // A completion with newer optimistic edits still queued must wake the host + // so it can schedule the next serialized runtime flush. + cx.notify(); + } + + pub fn begin_runtime_edit(&mut self) { + self.runtime_edits_in_flight = self.runtime_edits_in_flight.saturating_add(1); + } + + #[must_use] + pub fn runtime_edit_in_flight(&self) -> bool { + self.runtime_edits_in_flight > 0 + } + + /// Acknowledge canonical application of an optimistic editor batch. + /// + /// Local projection patches are echoes of mutations the editor has already + /// rendered. Reapplying those patches would transform the live caret twice + /// and can overwrite edits typed while the runtime request was in flight. + /// Advance only the canonical frontier, then rebase queued semantic commands + /// onto that acknowledged state. + pub fn acknowledge_runtime_edit(&mut self, frontier: Vec, selection: Option, cx: &mut Context) { + self.document.frontier.clone_from(&frontier); + for edit in &mut self.pending_semantic_edits { + edit.base_frontier.clone_from(&frontier); + } + self.complete_runtime_edit(selection, cx); + } + + pub fn complete_runtime_edit_replaying_pending( + &mut self, + frontier: Vec, + retry_edits: Vec, + selection: Option, + cx: &mut Context, + ) { + let mut edits = retry_edits; + edits.extend(std::mem::take(&mut self.pending_semantic_edits)); + if !edits.iter().any(|edit| !edit.semantic_commands.is_empty()) { + self.document.frontier.clone_from(&frontier); + self.complete_runtime_edit(selection, cx); + return; + } + + let mut document = self.document.clone(); + let mut replayed = Vec::with_capacity(edits.len()); + let mut replayed_selection = None; + let mut rejected = 0usize; + for mut edit in edits { + if edit.semantic_commands.is_empty() { + continue; + } + let mut candidate = document.clone(); + if edit + .semantic_commands + .iter() + .all(|command| replay_semantic_command_on_projection(&mut candidate, command)) + { + edit.base_frontier.clone_from(&frontier); + if let Some(selection) = &edit.selection_after { + replayed_selection = Some(selection.clone()); + } + document = candidate; + replayed.push(edit); + } else { + rejected += 1; + } + } + + if rejected > 0 { + eprintln!("dropped {rejected} unrebasable optimistic editor batch(es) after canonical runtime replay"); + } + + document.frontier.clone_from(&frontier); + let theme = self.document.theme.clone(); + self.document = projection_with_local_theme(document, &theme); + self.identity_map.reconcile(&self.document); + self.pending_semantic_edits = replayed; + if let Some(selection) = replayed_selection { + self.selection = selection; + clamp_selection_to_document(&self.document, &mut self.selection); + self.emit_selection_changed(cx); + } + self.after_text_mutation(cx); + let selection = if self.pending_semantic_edits.is_empty() { selection } else { None }; + self.complete_runtime_edit(selection, cx); + } + + pub fn replace_document_projection_replaying_pending( + &mut self, + document: DocumentProjection, + retry_edits: Vec, + selection: Option, + cx: &mut Context, + ) { + let has_replay = retry_edits.iter().any(|edit| !edit.semantic_commands.is_empty()) + || self.pending_semantic_edits.iter().any(|edit| !edit.semantic_commands.is_empty()); + let theme = self.document.theme.clone(); + self.document = projection_with_local_theme(document, &theme); + self.identity_map.reconcile(&self.document); + if !has_replay { + self.after_text_mutation(cx); + } + let frontier = self.document.frontier.clone(); + self.complete_runtime_edit_replaying_pending(frontier, retry_edits, selection, cx); + } + + pub fn restore_runtime_selection(&mut self, selection: EditorSelection, cx: &mut Context) { + self.selection = selection; + clamp_selection_to_document(&self.document, &mut self.selection); + self.emit_selection_changed(cx); + self.scroll_head_into_view(); + cx.notify(); + } + + pub fn mark_as_unsaved_branch(&mut self, cx: &mut Context) { + self.edit_generation = self.next_edit_generation; + self.next_edit_generation = self.next_edit_generation.wrapping_add(1); + self.refresh_save_status(); + cx.notify(); + } + + pub fn set_session_capture(&mut self, on: bool) { + self.set_command_capture_route(if on { + CommandCaptureRoute::Collaboration + } else if self.command_capture_route.accepts_collaboration() { + CommandCaptureRoute::Disabled + } else { + self.command_capture_route + }); + } + + pub fn set_runtime_capture(&mut self, on: bool) { + self.set_command_capture_route(if on { + CommandCaptureRoute::Runtime + } else if self.command_capture_route.accepts_runtime() { + CommandCaptureRoute::Disabled + } else { + self.command_capture_route + }); + if !on { + self.runtime_edits_in_flight = 0; + } + } + + fn set_command_capture_route(&mut self, route: CommandCaptureRoute) { + self.command_capture_route = route; + } + + pub(super) fn capture_semantic_edit(&mut self, edit: SemanticCommandBatch) { + if self.command_capture_route.is_enabled() && self.suppress_command_capture == 0 { + self.pending_semantic_edits.push(edit); + } + } + + pub(super) fn command_capture_enabled(&self) -> bool { + self.command_capture_route.is_enabled() + } + + pub(super) fn local_history_enabled(&self) -> bool { + !self.command_capture_enabled() && self.native_undo_hook.is_none() && self.session_undo_redirect.is_none() + } + + pub(super) fn record_local_history(&mut self, record: EditRecord) { + if self.local_history_enabled() { + self.undo_stack.push(record); + self.redo_stack.clear(); + } + } + + pub fn set_native_save_hook(&mut self, hook: Option) { + self.native_save_hook = hook; + } + + pub fn set_native_export_hook(&mut self, hook: Option) { + self.native_export_hook = hook; + } + + pub fn set_native_undo_hook(&mut self, hook: Option) { + self.native_undo_hook = hook; } - pub fn clear_collaboration_edit(&mut self) { - self.last_collaboration_edit = None; + pub fn set_native_recovery_hook(&mut self, hook: Option) { + self.native_recovery_hook = hook; } + + #[cfg(test)] + pub(crate) fn pending_scroll_head_after_layout_for_test(&self) -> bool { + self.pending_scroll_head_after_layout + } + + pub fn set_session_undo_redirect(&mut self, hook: Option>) { + self.session_undo_redirect = hook; + } + + pub fn clear_undo_redo_stacks(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } + + pub fn set_recovery_path(&mut self, path: Option, cx: &mut Context) { + self.recovery_path = path; + self.schedule_recovery_write(cx); + } + pub fn collaboration_role(&self) -> Option { self.collaboration_role } @@ -258,182 +513,29 @@ impl RichTextEditor { self.identity_map.block_id(block_ix) } - pub fn table_cell_id(&self, block_ix: usize, row_ix: usize, cell_ix: usize) -> Option { - self.identity_map.table_cell_id(block_ix, row_ix, cell_ix) - } - - pub fn apply_remote_operations(&mut self, operations: &[CanonicalOperation], cx: &mut Context) { - for operation in operations { - self.apply_canonical_operation(operation); - } - self.identity_map.reconcile(&self.document); - self.last_collaboration_edit = None; - self.after_text_mutation(cx); + fn semantic_block_id(&self, block_ix: usize) -> Option { + self + .identity_map + .block_id(block_ix) + .or_else(|| self.document.ids.block_ids.get(block_ix).copied()) } - pub fn replace_document_from_collaboration(&mut self, document: Document, cx: &mut Context) { - self.document = document; + pub fn replace_document_projection(&mut self, document: DocumentProjection, cx: &mut Context) { + self.document = projection_with_local_theme(document, &self.document.theme); self.identity_map.reconcile(&self.document); - self.last_collaboration_edit = None; self.after_text_mutation(cx); } - fn apply_canonical_operation(&mut self, operation: &CanonicalOperation) { - match operation { - CanonicalOperation::InsertText { - paragraph, - byte, - text, - styles, - } => { - if let Some(paragraph_ix) = self.identity_map.paragraph_index(*paragraph) - && paragraph_offset_in_bounds( - &self.document, - DocumentOffset { - paragraph: paragraph_ix, - byte: *byte, - }, - ) - { - insert_text_at(&mut self.document, paragraph_ix, *byte, text, *styles); - } - }, - CanonicalOperation::DeleteRange { - start_paragraph, - start_byte, - end_paragraph, - end_byte, - } => { - let Some(start_paragraph) = self.identity_map.paragraph_index(*start_paragraph) else { - return; - }; - let Some(end_paragraph) = self.identity_map.paragraph_index(*end_paragraph) else { - return; - }; - if paragraph_offset_in_bounds( - &self.document, - DocumentOffset { - paragraph: start_paragraph, - byte: *start_byte, - }, - ) && paragraph_offset_in_bounds( - &self.document, - DocumentOffset { - paragraph: end_paragraph, - byte: *end_byte, - }, - ) { - delete_cross_paragraph_range( - &mut self.document, - DocumentOffset { - paragraph: start_paragraph, - byte: *start_byte, - }..DocumentOffset { - paragraph: end_paragraph, - byte: *end_byte, - }, - ); - } - }, - CanonicalOperation::SplitParagraph { paragraph, byte, .. } => { - if let Some(paragraph_ix) = self.identity_map.paragraph_index(*paragraph) - && paragraph_offset_in_bounds( - &self.document, - DocumentOffset { - paragraph: paragraph_ix, - byte: *byte, - }, - ) - { - split_paragraph_at(&mut self.document, paragraph_ix, *byte); - } - }, - CanonicalOperation::JoinParagraphs { first, second } => { - let Some(first_ix) = self.identity_map.paragraph_index(*first) else { - return; - }; - let Some(second_ix) = self.identity_map.paragraph_index(*second) else { - return; - }; - if second_ix == first_ix + 1 { - let byte = paragraph_text_len(&self.document.paragraphs[first_ix]); - delete_cross_paragraph_range( - &mut self.document, - DocumentOffset { paragraph: first_ix, byte }..DocumentOffset { - paragraph: second_ix, - byte: 0, - }, - ); - } - }, - CanonicalOperation::SetParagraphStyle { paragraph, style } => { - if let Some(paragraph_ix) = self.identity_map.paragraph_index(*paragraph) - && let Some(paragraph) = paragraphs_mut(&mut self.document).get_mut(paragraph_ix) - { - paragraph.style = *style; - bump_paragraph_version(paragraph); - update_paragraph_block(&mut self.document, paragraph_ix); - rebuild_document_sections(&mut self.document); - } - }, - CanonicalOperation::SetRunStyles { paragraph, range, styles } => { - if let Some(paragraph_ix) = self.identity_map.paragraph_index(*paragraph) - && paragraph_offset_in_bounds( - &self.document, - DocumentOffset { - paragraph: paragraph_ix, - byte: range.start, - }, - ) - && paragraph_offset_in_bounds( - &self.document, - DocumentOffset { - paragraph: paragraph_ix, - byte: range.end, - }, - ) - { - mutate_runs_in_range( - &mut self.document, - DocumentOffset { - paragraph: paragraph_ix, - byte: range.start, - }..DocumentOffset { - paragraph: paragraph_ix, - byte: range.end, - }, - |run_styles| *run_styles = *styles, - ); - } - }, - CanonicalOperation::ReplaceParagraphSpan { - start_paragraph, - before, - after, - } => { - let start = start_paragraph - .and_then(|id| self.identity_map.paragraph_index(id)) - .unwrap_or(before.start_paragraph); - let current = capture_document_span(&self.document, start..start + before.paragraphs.len()); - let replacement = DocumentSpan { - start_paragraph: start, - paragraphs: after.paragraphs.clone(), - text: after.text.clone(), - }; - apply_document_span_replacement(&mut self.document, ¤t, &replacement); - }, - CanonicalOperation::InsertBlock { .. } - | CanonicalOperation::DeleteBlock { .. } - | CanonicalOperation::MoveBlock { .. } - | CanonicalOperation::ReplaceBlock { .. } - | CanonicalOperation::ReplaceDocument => {}, - } - } - pub fn document_path(&self) -> Option<&PathBuf> { self.document_path.as_ref() } + pub fn set_document_path_for_runtime(&mut self, path: PathBuf, cx: &mut Context) { + self.document_path = Some(path.clone()); + self.recovery_path = Some(recovery_path_for_document(&path)); + cx.notify(); + } + pub fn set_document_display_name(&mut self, name: SharedString, cx: &mut Context) { self.document_display_name = Some(name); cx.notify(); @@ -460,6 +562,28 @@ impl RichTextEditor { cx.notify(); } + pub fn set_show_own_collaboration_caret_color(&mut self, enabled: bool, cx: &mut Context) { + if self.config.show_own_collaboration_caret_color != enabled { + self.config.show_own_collaboration_caret_color = enabled; + cx.notify(); + } + } + + pub fn set_own_collaboration_caret_color(&mut self, color_rgb: Option, cx: &mut Context) { + if self.own_collaboration_caret_color_rgb != color_rgb { + self.own_collaboration_caret_color_rgb = color_rgb; + cx.notify(); + } + } + + pub(super) fn local_caret_color_rgb(&self) -> Option { + self + .config + .show_own_collaboration_caret_color + .then_some(self.own_collaboration_caret_color_rgb) + .flatten() + } + pub fn save_status(&self) -> &SaveStatus { &self.save_status } @@ -485,9 +609,414 @@ impl RichTextEditor { } } -fn paragraph_offset_in_bounds(document: &Document, offset: DocumentOffset) -> bool { +fn replay_semantic_command_on_projection(document: &mut DocumentProjection, command: &SemanticEditCommand) -> bool { + match command { + SemanticEditCommand::InsertText { at, text, styles } => { + if !valid_document_offset(document, *at) { + return false; + } + insert_text_at(document, at.paragraph, at.byte, text, *styles); + true + }, + SemanticEditCommand::DeleteRange { range } => { + if range.start > range.end || !valid_document_offset(document, range.start) || !valid_document_offset(document, range.end) { + return false; + } + if range.start.paragraph == range.end.paragraph { + delete_range_in_paragraph(document, range.start.paragraph, range.start.byte..range.end.byte); + } else { + delete_cross_paragraph_range(document, range.clone()); + } + true + }, + SemanticEditCommand::SplitParagraph { at, inherited_style } => { + if !valid_document_offset(document, *at) { + return false; + } + split_paragraph_at(document, at.paragraph, at.byte); + let mut updated_style = false; + if let Some(paragraph) = paragraphs_mut(document).get_mut(at.paragraph + 1) + && paragraph.style != *inherited_style + { + paragraph.style = *inherited_style; + bump_paragraph_version(paragraph); + updated_style = true; + } + if updated_style { + update_paragraph_block(document, at.paragraph + 1); + rebuild_document_sections(document); + } + true + }, + SemanticEditCommand::JoinParagraphs { first, second } => { + let Some(first_ix) = paragraph_index_for_id(document, *first) else { + return false; + }; + let Some(second_ix) = paragraph_index_for_id(document, *second) else { + return false; + }; + if first_ix + 1 != second_ix { + return false; + } + let byte = paragraph_text_len(&document.paragraphs[first_ix]); + delete_cross_paragraph_range( + document, + DocumentOffset { paragraph: first_ix, byte }..DocumentOffset { + paragraph: second_ix, + byte: 0, + }, + ); + true + }, + SemanticEditCommand::SetParagraphStyle { paragraph, style } => { + let Some(paragraph_ix) = paragraph_index_for_id(document, *paragraph) else { + return false; + }; + let mut updated_style = false; + if let Some(paragraph) = paragraphs_mut(document).get_mut(paragraph_ix) + && paragraph.style != *style + { + paragraph.style = *style; + bump_paragraph_version(paragraph); + updated_style = true; + } + if updated_style { + update_paragraph_block(document, paragraph_ix); + rebuild_document_sections(document); + } + true + }, + SemanticEditCommand::SetRunStyles { paragraph, range, styles } => { + let Some(paragraph_ix) = paragraph_index_for_id(document, *paragraph) else { + return false; + }; + let Some(paragraph) = document.paragraphs.get(paragraph_ix) else { + return false; + }; + if range.start > range.end || range.end > paragraph_text_len(paragraph) { + return false; + } + mutate_runs_in_range( + document, + DocumentOffset { + paragraph: paragraph_ix, + byte: range.start, + }..DocumentOffset { + paragraph: paragraph_ix, + byte: range.end, + }, + |run_styles| *run_styles = *styles, + ); + true + }, + SemanticEditCommand::ReplaceParagraphSpan { start, before, after } => { + let start_paragraph = start + .map(|offset| offset.paragraph) + .unwrap_or(before.start_paragraph) + .min(document.paragraphs.len()); + let old_count = before + .paragraphs + .len() + .min(document.paragraphs.len().saturating_sub(start_paragraph)); + let current = capture_document_span(document, start_paragraph..start_paragraph + old_count); + let mut replacement = after.clone(); + replacement.start_paragraph = start_paragraph; + apply_document_span_replacement(document, ¤t, &replacement); + true + }, + SemanticEditCommand::InsertBlock { block, block_ix, after } => { + let mut blocks = projection_structural_blocks_from_document(document); + let row = (*block_ix).min(blocks.len()); + blocks.insert(row, structural_block_for_input(*block, None, after.clone())); + rebuild_document_from_projection_structural_blocks(document, blocks); + true + }, + SemanticEditCommand::DeleteBlock { block } => { + let Some(block_ix) = document.ids.block_ids.iter().position(|id| id == block) else { + return false; + }; + let mut blocks = projection_structural_blocks_from_document(document); + if block_ix >= blocks.len() { + return false; + } + blocks.remove(block_ix); + rebuild_document_from_projection_structural_blocks(document, blocks); + true + }, + SemanticEditCommand::MoveBlock { block, new_block_ix } => { + let Some(block_ix) = document.ids.block_ids.iter().position(|id| id == block) else { + return false; + }; + let mut blocks = projection_structural_blocks_from_document(document); + if block_ix >= blocks.len() { + return false; + } + let block = blocks.remove(block_ix); + blocks.insert((*new_block_ix).min(blocks.len()), block); + rebuild_document_from_projection_structural_blocks(document, blocks); + true + }, + SemanticEditCommand::ReplaceBlock { block, block_ix, after } => { + let row = block + .and_then(|block| document.ids.block_ids.iter().position(|id| *id == block)) + .unwrap_or(*block_ix); + let mut blocks = projection_structural_blocks_from_document(document); + if row >= blocks.len() { + return false; + } + let block_id = block.unwrap_or(blocks[row].block_id); + blocks[row] = structural_block_for_input(block_id, blocks[row].paragraph_id, after.clone()); + rebuild_document_from_projection_structural_blocks(document, blocks); + true + }, + SemanticEditCommand::InsertTableRow { table, row_ix, row } => replay_table_edit(document, *table, |table| { + table + .rows + .insert((*row_ix).min(table.rows.len()), table_row_from_input_row(row)); + true + }), + SemanticEditCommand::DeleteTableRow { table, row_ix } => replay_table_edit(document, *table, |table| { + if *row_ix >= table.rows.len() { + return false; + } + table.rows.remove(*row_ix); + true + }), + SemanticEditCommand::MoveTableRow { + table, + from_row_ix, + to_row_ix, + } => replay_table_edit(document, *table, |table| { + if *from_row_ix >= table.rows.len() || *to_row_ix >= table.rows.len() { + return false; + } + let row = table.rows.remove(*from_row_ix); + table.rows.insert(*to_row_ix, row); + true + }), + SemanticEditCommand::InsertTableColumn { + table, + column_ix, + width, + cells, + } => replay_table_edit(document, *table, |table| { + let column_ix = (*column_ix).min(table.column_widths.len()); + table.column_widths.insert(column_ix, table_column_width_from_input_width(width)); + for (row_ix, row) in table.rows.iter_mut().enumerate() { + row.cells.insert( + column_ix.min(row.cells.len()), + cells + .get(row_ix) + .map(table_cell_from_input_cell) + .unwrap_or_else(default_table_cell), + ); + } + true + }), + SemanticEditCommand::DeleteTableColumn { table, column_ix } => replay_table_edit(document, *table, |table| { + if *column_ix >= table.column_widths.len() { + return false; + } + table.column_widths.remove(*column_ix); + for row in &mut table.rows { + if *column_ix < row.cells.len() { + row.cells.remove(*column_ix); + } + } + true + }), + SemanticEditCommand::MoveTableColumn { + table, + from_column_ix, + to_column_ix, + } => replay_table_edit(document, *table, |table| { + if *from_column_ix >= table.column_widths.len() || *to_column_ix >= table.column_widths.len() { + return false; + } + let width = table.column_widths.remove(*from_column_ix); + table.column_widths.insert(*to_column_ix, width); + for row in &mut table.rows { + if *from_column_ix < row.cells.len() && *to_column_ix < row.cells.len() { + let cell = row.cells.remove(*from_column_ix); + row.cells.insert(*to_column_ix, cell); + } + } + true + }), + SemanticEditCommand::ReplaceTableCell { + table, + row_ix, + cell_ix, + cell, + } => replay_table_edit(document, *table, |table| { + let Some(row) = table.rows.get_mut(*row_ix) else { + return false; + }; + let Some(target) = row.cells.get_mut(*cell_ix) else { + return false; + }; + *target = table_cell_from_input_cell(cell); + true + }), + SemanticEditCommand::SetTableCellSpan { + table, + row_ix, + cell_ix, + row_span, + column_span, + } => replay_table_edit(document, *table, |table| { + let Some(row) = table.rows.get_mut(*row_ix) else { + return false; + }; + let Some(cell) = row.cells.get_mut(*cell_ix) else { + return false; + }; + cell.row_span = (*row_span).max(1); + cell.col_span = (*column_span).max(1); + true + }), + SemanticEditCommand::SetTableColumnWidth { table, column_ix, width } => replay_table_edit(document, *table, |table| { + let Some(column) = table.column_widths.get_mut(*column_ix) else { + return false; + }; + *column = table_column_width_from_input_width(width); + true + }), + SemanticEditCommand::ReplaceEquationSourceRange { equation, range, text } => { + let Some(block_ix) = document.ids.block_ids.iter().position(|id| id == equation) else { + return false; + }; + let Some(Block::Equation(equation)) = Arc::make_mut(&mut document.blocks).get_mut(block_ix) else { + return false; + }; + let mut source = equation.source.to_string(); + if range.start > range.end || range.end > source.len() || !source.is_char_boundary(range.start) || !source.is_char_boundary(range.end) { + return false; + } + source.replace_range(range.clone(), text); + equation.source = source.into(); + equation.version = equation.version.wrapping_add(1); + true + }, + SemanticEditCommand::ReplaceImageAltText { image, text } => replay_image_edit(document, *image, |image| { + image.alt_text = text.clone().into(); + true + }), + SemanticEditCommand::ReplaceImageCaption { image, caption } => replay_image_edit(document, *image, |image| { + image.caption = caption.as_ref().map(paragraph_from_input_paragraph); + true + }), + SemanticEditCommand::SetImageLayout { image, sizing, alignment } => replay_image_edit(document, *image, |image| { + image.sizing = image_sizing_from_input_sizing(sizing); + image.alignment = alignment_from_input_alignment(*alignment); + true + }), + } +} + +fn valid_document_offset(document: &DocumentProjection, offset: DocumentOffset) -> bool { + let Some(paragraph) = document.paragraphs.get(offset.paragraph) else { + return false; + }; + if offset.byte > paragraph_text_len(paragraph) { + return false; + } document - .paragraphs - .get(offset.paragraph) - .is_some_and(|paragraph| offset.byte <= paragraph_text_len(paragraph)) + .text + .is_char_boundary(paragraph_byte_range(document, offset.paragraph).start + offset.byte) +} + +fn structural_block_for_input(block_id: BlockId, paragraph_id: Option, block: InputBlock) -> ProjectionStructuralBlock { + ProjectionStructuralBlock { + block_id, + paragraph_id: match &block { + InputBlock::Paragraph(_) => Some(paragraph_id.unwrap_or_else(new_paragraph_id)), + InputBlock::Image(_) | InputBlock::Equation(_) | InputBlock::Table(_) => None, + }, + block, + } +} + +fn replay_table_edit(document: &mut DocumentProjection, table_id: BlockId, edit: impl FnOnce(&mut TableBlock) -> bool) -> bool { + let Some(block_ix) = document.ids.block_ids.iter().position(|id| *id == table_id) else { + return false; + }; + let Some(Block::Table(table)) = Arc::make_mut(&mut document.blocks).get_mut(block_ix) else { + return false; + }; + if !edit(table) { + return false; + } + table.version = table.version.wrapping_add(1); + true +} + +fn replay_image_edit(document: &mut DocumentProjection, image_id: BlockId, edit: impl FnOnce(&mut ImageBlock) -> bool) -> bool { + let Some(block_ix) = document.ids.block_ids.iter().position(|id| *id == image_id) else { + return false; + }; + let Some(Block::Image(image)) = Arc::make_mut(&mut document.blocks).get_mut(block_ix) else { + return false; + }; + if !edit(image) { + return false; + } + image.version = image.version.wrapping_add(1); + true +} + +fn table_row_from_input_row(row: &InputTableRow) -> TableRow { + TableRow { + cells: row.cells.iter().map(table_cell_from_input_cell).collect(), + } +} + +fn table_cell_from_input_cell(cell: &InputTableCell) -> TableCell { + TableCell { + blocks: cell + .blocks + .iter() + .map(|block| match block { + InputTableCellBlock::Paragraph(paragraph) => TableCellBlock::Paragraph(table_cell_paragraph_from_input_paragraph(paragraph)), + InputTableCellBlock::Table(table) => TableCellBlock::Table(table_from_input_table(table)), + }) + .collect(), + row_span: cell.row_span, + col_span: cell.col_span, + } +} + +fn table_column_width_from_input_width(width: &InputTableColumnWidth) -> TableColumnWidth { + match *width { + InputTableColumnWidth::Auto => TableColumnWidth::Auto, + InputTableColumnWidth::FixedPx(px) => TableColumnWidth::FixedPx(px), + InputTableColumnWidth::Fraction(fraction) => TableColumnWidth::Fraction(fraction), + } +} + +fn image_sizing_from_input_sizing(sizing: &InputImageSizing) -> ImageSizing { + match *sizing { + InputImageSizing::Intrinsic => ImageSizing::Intrinsic, + InputImageSizing::FitWidth => ImageSizing::FitWidth, + InputImageSizing::Fixed { width_px, height_px } => ImageSizing::Fixed { width_px, height_px }, + } +} + +#[cfg(test)] +mod projection_theme_tests { + use super::*; + + #[test] + fn replacement_projection_preserves_local_theme_catalog() { + let mut current = blank_document(); + current.theme.body_font_size = px(18.0); + current.theme.custom_highlight_styles.insert(77, CustomHighlightStyle { + color: rgb(0x0012_3456).into(), + }); + + let replacement = projection_with_local_theme(blank_document(), ¤t.theme); + + assert_eq!(replacement.theme.body_font_size, px(18.0)); + assert!(replacement.theme.custom_highlight_styles.contains_key(&77)); + } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/media.rs b/crates/gpui-flowtext/src/rich_text/editor/media.rs index b7dbd8ae..cca0e560 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/media.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/media.rs @@ -151,7 +151,29 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + let after_block = Block::Image(after.clone()); + let semantic_commands = match (self.semantic_block_id(drag.block_ix), input_block_from_block(&after_block)) { + (Some(image_id), InputBlock::Image(image)) => vec![SemanticEditCommand::SetImageLayout { + image: image_id, + sizing: image.sizing, + alignment: image.alignment, + }], + (None, _) => { + eprintln!( + "skipping image resize semantic command because projection block {} has no durable id", + drag.block_ix + ); + Vec::new() + }, + (Some(image_id), _) => { + eprintln!( + "skipping image resize semantic command because projection block {} ({image_id:?}) is no longer an image", + drag.block_ix + ); + Vec::new() + }, + }; + self.record_local_history(EditRecord { before_selection: self.selection.clone(), before_generation, after_selection: self.selection.clone(), @@ -161,13 +183,10 @@ impl RichTextEditor { before: Block::Image(drag.before), after: Block::Image(after), }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(drag.block_ix), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); true } @@ -192,13 +211,48 @@ impl RichTextEditor { return; }; let alt_text = alt_text.into(); - self.edit_selected_image(block_ix, cx, |image| { + let semantic_alt_text = alt_text.to_string(); + self.edit_selected_image_with_semantic(block_ix, cx, |image| { image.alt_text = alt_text; image.version = image.version.wrapping_add(1); + }, |editor, block_ix, _after| { + if let Some(image_id) = editor.semantic_block_id(block_ix) { + vec![SemanticEditCommand::ReplaceImageAltText { + image: image_id, + text: semantic_alt_text, + }] + } else { + eprintln!("skipping image alt-text semantic command because projection block {block_ix} has no durable id"); + Vec::new() + } }); } fn edit_selected_image(&mut self, block_ix: usize, cx: &mut Context, update: impl FnOnce(&mut ImageBlock)) { + self.edit_selected_image_with_semantic(block_ix, cx, update, |editor, block_ix, after| { + let Some(image_id) = editor.semantic_block_id(block_ix) else { + eprintln!("skipping image layout semantic command because projection block {block_ix} has no durable id"); + return Vec::new(); + }; + let InputBlock::Image(image) = input_block_from_block(after) else { + eprintln!("skipping image layout semantic command because projection block {block_ix} ({image_id:?}) is no longer an image"); + return Vec::new(); + }; + vec![SemanticEditCommand::SetImageLayout { + image: image_id, + sizing: image.sizing, + alignment: image.alignment, + }] + }); + } + + fn edit_selected_image_with_semantic( + &mut self, + block_ix: usize, + cx: &mut Context, + update: impl FnOnce(&mut ImageBlock), + semantic_commands: impl FnOnce(&Self, usize, &Block) -> Vec, + ) { let Some(Block::Image(image)) = self.document.blocks.get(block_ix).cloned() else { return; }; @@ -215,19 +269,17 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + let semantic_commands = semantic_commands(self, block_ix, &after); + self.record_local_history(EditRecord { before_selection: self.selection.clone(), before_generation, after_selection: self.selection.clone(), after_generation, operations: vec![EditOperation::ReplaceBlock { block_ix, before, after }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(block_ix), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); } pub fn insert_equation(&mut self, source: impl Into, cx: &mut Context) { @@ -265,8 +317,14 @@ impl RichTextEditor { version: 0, })); } - self.insert_blocks_after_caret_without_history(blocks); - self.push_replace_document_history(before_document, before_selection, cx); + let inserted_range = self.insert_blocks_after_caret_without_history(blocks, cx); + let semantic_commands = self.insert_block_semantic_commands(&before_document, &before_selection, inserted_range); + self.push_document_snapshot_history( + before_document, + before_selection, + semantic_commands.unwrap_or_default(), + cx, + ); } pub fn prompt_insert_image(&mut self, cx: &mut Context) { @@ -360,10 +418,7 @@ impl RichTextEditor { } } let offset = self.hit_test_document_position(position, window, cx); - self.selection = EditorSelection { - anchor: offset, - head: offset, - }; + self.selection = EditorSelection::collapsed(offset); self.clear_block_selection(); self.goal_x = None; self.reset_caret_blink(cx); diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index 646f429d..41cc91fa 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -1,10 +1,12 @@ use std::{ collections::{VecDeque, hash_map::DefaultHasher}, fs, + future::Future, hash::{Hash, Hasher}, io, ops::Range, path::{Path, PathBuf}, + pin::Pin, rc::Rc, sync::{Arc, Mutex, OnceLock}, time::{Duration, Instant}, @@ -12,9 +14,10 @@ use std::{ use crop::Rope; use gpui::{ - App, Bounds, ClipboardEntry, ClipboardItem, Context, CursorStyle, DragMoveEvent, Entity, ExternalPaths, FocusHandle, Focusable, Image, - ImageFormat, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PathPromptOptions, - Pixels, Point, Render, SharedString, Size, Subscription, Task, Timer, Window, actions, div, img, point, prelude::*, px, relative, rgb, size, + App, Bounds, ClipboardEntry, ClipboardItem, Context, CursorStyle, DragMoveEvent, Entity, EntityInputHandler, ExternalPaths, FocusHandle, + Focusable, Image, ImageFormat, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + PathPromptOptions, Pixels, Point, Render, SharedString, Size, Subscription, Task, Timer, UTF16Selection, Window, actions, div, img, point, + prelude::*, px, relative, rgb, size, }; use gpui_component::ActiveTheme as _; use gpui_component::scroll::{Scrollbar, ScrollbarHandle, ScrollbarShow}; @@ -28,7 +31,9 @@ const DISABLE_SCROLL_LIMITING_FUNCTIONS: bool = true; // cfg!(target_os = "linux const SCROLL_FOREGROUND_OVERSCAN_PX: f32 = 384.0; const SCROLL_FOREGROUND_MATERIALIZE_BUDGET_MS: u64 = 8; const SCROLL_FOREGROUND_MAX_CHUNK_LINES: usize = 96; -const TYPING_PREFETCH_SUPPRESSION_WINDOW: Duration = Duration::from_millis(150); +const TYPING_PREFETCH_SUPPRESSION_WINDOW: Duration = Duration::from_millis(500); +const RECOVERY_WRITE_DEBOUNCE: Duration = Duration::from_millis(750); +const RECOVERY_TYPING_IDLE_WINDOW: Duration = Duration::from_secs(2); const OFFSCREEN_LAYOUT_CACHE_OVERSCAN_PARAGRAPHS: usize = 24; const OFFSCREEN_PREP_CACHE_OVERSCAN_PARAGRAPHS: usize = 160; @@ -156,17 +161,96 @@ impl Render for ToolkitTextDrag { } } -#[derive(Clone, Debug, Eq, PartialEq)] +/// Editor selection (§16). +/// +/// `anchor`/`head` remain plain [`DocumentOffset`]s so all existing +/// position/range math keeps working unchanged. Each endpoint additionally +/// carries an explicit [`SelectionAffinity`] and [`VisualGravity`] describing +/// the user's intent. These are a genuine, stored part of the selection — the +/// collaboration runtime reads them directly instead of re-deriving a side from +/// selection direction. +/// +/// Construct selections through the helpers below ([`EditorSelection::collapsed`], +/// [`EditorSelection::range`], [`EditorSelection::moved`]) rather than struct +/// literals so the affinity/gravity fields are always populated; all default to +/// neutral. +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct EditorSelection { pub anchor: DocumentOffset, pub head: DocumentOffset, + /// Affinity of the fixed (anchor) endpoint. + pub anchor_affinity: SelectionAffinity, + /// Affinity of the moving (head) endpoint. This is the side the caret sits on + /// for a collapsed selection. + pub head_affinity: SelectionAffinity, + /// Visual gravity of the fixed (anchor) endpoint at a soft-wrap seam. + pub anchor_gravity: VisualGravity, + /// Visual gravity of the moving (head) endpoint at a soft-wrap seam. This is + /// the gravity consumed when painting a collapsed caret. + pub head_gravity: VisualGravity, } #[hotpath::measure_all] impl EditorSelection { fn caret() -> Self { - let zero = DocumentOffset::default(); - Self { anchor: zero, head: zero } + Self::default() + } + + /// Collapsed caret at `offset` with neutral affinity/gravity. + #[must_use] + pub fn collapsed(offset: DocumentOffset) -> Self { + Self { + anchor: offset, + head: offset, + ..Self::default() + } + } + + /// Collapsed caret at `offset` carrying an explicit affinity/gravity. Used by + /// boundary navigation (objects, line edges) where the side/gravity matters + /// even though the selection is collapsed. + #[must_use] + pub fn collapsed_with(offset: DocumentOffset, affinity: SelectionAffinity, gravity: VisualGravity) -> Self { + Self { + anchor: offset, + head: offset, + anchor_affinity: affinity, + head_affinity: affinity, + anchor_gravity: gravity, + head_gravity: gravity, + } + } + + /// Range selection from `anchor` to `head` with neutral affinity/gravity on + /// both endpoints. + #[must_use] + pub fn range(anchor: DocumentOffset, head: DocumentOffset) -> Self { + Self { + anchor, + head, + ..Self::default() + } + } + + /// Apply a caret motion: move `head` to `new_head` carrying the supplied + /// affinity/gravity. When `extend` is set the anchor (and its intent) is + /// preserved, producing/continuing a range; otherwise the selection collapses + /// onto the new head and the anchor mirrors the head's intent. This preserves + /// the moving endpoint's affinity across selection extension (§16). + #[must_use] + pub(super) fn moved(&self, new_head: DocumentOffset, head_affinity: SelectionAffinity, head_gravity: VisualGravity, extend: bool) -> Self { + if extend { + Self { + anchor: self.anchor, + head: new_head, + anchor_affinity: self.anchor_affinity, + head_affinity, + anchor_gravity: self.anchor_gravity, + head_gravity, + } + } else { + Self::collapsed_with(new_head, head_affinity, head_gravity) + } } pub(super) fn normalized(&self) -> Range { @@ -176,6 +260,13 @@ impl EditorSelection { pub fn is_caret(&self) -> bool { self.anchor == self.head } + + /// Whether two selections occupy the same anchor/head offsets, ignoring + /// affinity/gravity. Used by directional movement to detect a true positional + /// no-op (preserving the historical offset-based early-return behavior). + pub(super) fn same_positions(&self, other: &Self) -> bool { + self.anchor == other.anchor && self.head == other.head + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -185,6 +276,12 @@ pub enum CollaborationRole { Viewer, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UndoRedirect { + Undo, + Redo, +} + impl CollaborationRole { #[must_use] pub const fn can_write(self) -> bool { @@ -192,13 +289,13 @@ impl CollaborationRole { } } -struct EditRecord { +pub(super) struct EditRecord { before_selection: EditorSelection, before_generation: u64, after_selection: EditorSelection, after_generation: u64, operations: Vec, - canonical_operations: Vec, + semantic_commands: Vec, } #[derive(Clone, Debug)] @@ -232,9 +329,9 @@ pub(super) enum EditOperation { before: Block, after: Block, }, - ReplaceDocument { - before: Box, - after: Box, + RestoreProjectionSnapshot { + before: Box, + after: Box, }, MoveRichText { source_range: Range, @@ -246,7 +343,7 @@ pub(super) enum EditOperation { #[hotpath::measure_all] impl EditOperation { - pub(super) fn undo(&self, document: &mut Document) { + pub(super) fn undo(&self, document: &mut DocumentProjection) { match self { Self::InsertText { paragraph, byte, text, .. } => { delete_range_in_paragraph(document, *paragraph, *byte..*byte + text.len()); @@ -266,7 +363,7 @@ impl EditOperation { *block = before.clone(); } }, - Self::ReplaceDocument { before, .. } => { + Self::RestoreProjectionSnapshot { before, .. } => { *document = before.as_ref().clone(); }, Self::MoveRichText { @@ -281,7 +378,7 @@ impl EditOperation { } } - pub(super) fn redo(&self, document: &mut Document) { + pub(super) fn redo(&self, document: &mut DocumentProjection) { match self { Self::InsertText { paragraph, @@ -309,7 +406,7 @@ impl EditOperation { *block = after.clone(); } }, - Self::ReplaceDocument { after, .. } => { + Self::RestoreProjectionSnapshot { after, .. } => { *document = after.as_ref().clone(); }, Self::MoveRichText { @@ -448,20 +545,45 @@ pub struct RichTextEditorStyleState { #[derive(Clone, Debug, Eq, PartialEq)] pub struct RichTextEditorConfig { pub smart_word_selection: bool, + pub show_own_collaboration_caret_color: bool, } #[hotpath::measure_all] impl Default for RichTextEditorConfig { fn default() -> Self { - Self { smart_word_selection: true } + Self { + smart_word_selection: true, + show_own_collaboration_caret_color: true, + } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExternalCaret { pub offset: DocumentOffset, + pub visual_gravity: VisualGravity, pub color_rgb: u32, } + +pub type NativeSaveHook = + Rc, Vec) -> Pin>>>>; +pub type NativeExportHook = Rc< + dyn Fn( + PathBuf, + DocumentExportFormat, + Vec, + Vec, + ) -> Pin>>>, +>; +pub type NativeUndoHook = + Rc, Vec) -> Pin>>>>>; +pub type NativeRecoveryHook = Rc Pin>>>>; + +pub struct NativeUndoResult { + pub document: DocumentProjection, + pub selection: Option, +} + #[derive(Clone)] struct ParagraphChunkLayoutCacheEntry { key: ParagraphCacheKey, @@ -722,27 +844,31 @@ struct RenderLayoutSnapshot { #[derive(Clone)] enum RenderVirtualItems { - Document(Rc>), + DocumentProjection(Rc>), WithDropPreview(Rc>), } #[derive(Clone)] enum RenderVirtualItem { - Document(VirtualItem), + DocumentProjection(VirtualItem), DropPreview, } impl RenderVirtualItems { fn get(&self, item_ix: usize) -> Option { match self { - Self::Document(items) => items.get(item_ix).cloned().map(RenderVirtualItem::Document), + Self::DocumentProjection(items) => items + .get(item_ix) + .cloned() + .map(RenderVirtualItem::DocumentProjection), Self::WithDropPreview(items) => items.get(item_ix).cloned(), } } } enum RecoveryWriteDecision { - Write { generation: u64, document: Box }, + Write { generation: u64, document: Box }, + WriteRuntime { generation: u64, hook: NativeRecoveryHook }, Rescheduled, Idle, } @@ -846,6 +972,28 @@ impl HeightPrefixIndex { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) enum CommandCaptureRoute { + #[default] + Disabled, + Runtime, + Collaboration, +} + +impl CommandCaptureRoute { + fn accepts_runtime(self) -> bool { + matches!(self, Self::Runtime) + } + + fn accepts_collaboration(self) -> bool { + matches!(self, Self::Collaboration) + } + + fn is_enabled(self) -> bool { + !matches!(self, Self::Disabled) + } +} + pub struct RichTextEditor { pub(super) focus_handle: FocusHandle, focus_subscriptions: Vec, @@ -854,7 +1002,7 @@ pub struct RichTextEditor { document_path: Option, document_display_name: Option, recovery_path: Option, - pub(super) document: Document, + pub(super) document: DocumentProjection, pub(super) selection: EditorSelection, config: RichTextEditorConfig, edit_generation: u64, @@ -867,8 +1015,17 @@ pub struct RichTextEditor { undo_stack: Vec, redo_stack: Vec, identity_map: DocumentIdentityMap, - last_collaboration_edit: Option, + pending_semantic_edits: Vec, + runtime_edits_in_flight: usize, + command_capture_route: CommandCaptureRoute, + native_save_hook: Option, + native_export_hook: Option, + native_undo_hook: Option, + native_recovery_hook: Option, + suppress_command_capture: u32, + session_undo_redirect: Option>, collaboration_role: Option, + own_collaboration_caret_color_rgb: Option, recovery_write_in_progress: bool, recovery_write_pending: bool, last_recovery_generation: u64, @@ -898,6 +1055,7 @@ pub struct RichTextEditor { pub(super) caret_visible: bool, caret_blink_active: bool, last_text_input_at: Option, + ime_marked_range: Option>, external_carets: Vec, pub(super) search_highlights: Vec>, pub(super) active_search_highlight: Option, @@ -944,7 +1102,10 @@ pub struct RichTextEditor { goal_x: Option, } +impl gpui::EventEmitter for RichTextEditor {} + include!("lifecycle.rs"); +include!("projection_apply.rs"); include!("object_selection.rs"); include!("style_state.rs"); include!("search_highlights.rs"); diff --git a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs index d5444fa7..6e24028b 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs @@ -35,20 +35,20 @@ impl RichTextEditor { 2 => SelectionGranularity::Word, _ => SelectionGranularity::Paragraph, }; + let before_selection = self.selection.clone(); + // Mouse placement carries no explicit affinity/gravity (§16): clicks/drags + // produce neutral endpoints. self.selection = match self.drag_granularity { - SelectionGranularity::Character if event.modifiers.shift => EditorSelection { - anchor: self.selection.anchor, - head: offset, - }, - SelectionGranularity::Character => EditorSelection { - anchor: offset, - head: offset, - }, + SelectionGranularity::Character if event.modifiers.shift => EditorSelection::range(self.selection.anchor, offset), + SelectionGranularity::Character => EditorSelection::collapsed(offset), SelectionGranularity::Word => selection_for_word_at(&self.document, offset), SelectionGranularity::Paragraph => selection_for_paragraph_at(&self.document, offset.paragraph), }; self.drag_anchor = Some(self.selection.anchor); self.reset_caret_blink(cx); + if self.selection != before_selection { + self.emit_selection_changed(cx); + } cx.notify(); } @@ -78,6 +78,7 @@ impl RichTextEditor { paragraph: paragraph_ix, byte: paragraph_text_len(paragraph), }, + VisualGravity::Neutral, origin, ) else { return false; @@ -150,7 +151,11 @@ impl RichTextEditor { source_range: source_range.clone(), fragment: selected_rich_fragment(&self.document, source_range), }); + let before_selection = self.selection.clone(); self.selection = pending_drag.source_selection; + if self.selection != before_selection { + self.emit_selection_changed(cx); + } self.pending_text_drag = None; } if self.active_text_drag.is_some() { @@ -158,11 +163,12 @@ impl RichTextEditor { self.autoscroll_for_drag(event.position); self.ensure_drag_autoscroll_task(cx); let drop = self.hit_test_document_position(event.position, window, cx); - let selection = EditorSelection { anchor: drop, head: drop }; + let selection = EditorSelection::collapsed(drop); if self.selection != selection { self.selection = selection; self.scroll_head_into_view(); self.reset_caret_blink(cx); + self.emit_selection_changed(cx); } cx.notify(); return; @@ -196,6 +202,7 @@ impl RichTextEditor { self.selection = selection; self.scroll_head_into_view(); self.reset_caret_blink(cx); + self.emit_selection_changed(cx); cx.notify(); } else { cx.notify(); @@ -230,9 +237,13 @@ impl RichTextEditor { } else if self.pending_text_drag.take().is_some() { self.clear_drop_preview(); let caret = self.hit_test_document_position(event.position, window, cx); - self.selection = EditorSelection { anchor: caret, head: caret }; + let before_selection = self.selection.clone(); + self.selection = EditorSelection::collapsed(caret); self.scroll_head_into_view(); self.reset_caret_blink(cx); + if self.selection != before_selection { + self.emit_selection_changed(cx); + } cx.notify(); } if self.selecting { @@ -251,31 +262,52 @@ impl RichTextEditor { fn move_rich_text_fragment(&mut self, drag: ActiveTextDrag, drop: DocumentOffset, cx: &mut Context) { if offset_in_range(drop, drag.source_range.clone()) { self.clear_drop_preview(); - self.selection = EditorSelection { - anchor: drag.source_range.start, - head: drag.source_range.end, - }; + let before_selection = self.selection.clone(); + self.selection = EditorSelection::range(drag.source_range.start, drag.source_range.end); + if self.selection != before_selection { + self.emit_selection_changed(cx); + } cx.notify(); return; } - let before_selection = EditorSelection { - anchor: drag.source_range.start, - head: drag.source_range.end, - }; + let before_document = self.document.clone(); + let before_selection = EditorSelection::range(drag.source_range.start, drag.source_range.end); let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); let source_range = drag.source_range.clone(); let adjusted_drop = adjust_drop_after_source_delete(drop, source_range.clone()); + let capture_start = source_range.start.paragraph.min(drop.paragraph); + let capture_end = source_range.end.paragraph.max(drop.paragraph).saturating_add(1); + let before_span = capture_document_span(&before_document, capture_start..capture_end); self.selection = before_selection.clone(); self.delete_selection_internal(); let inserted_start = adjusted_drop; let inserted_end = insert_rich_fragment_at(&mut self.document, inserted_start, &drag.fragment); - self.selection = EditorSelection { - anchor: inserted_end, - head: inserted_end, - }; - self.undo_stack.push(EditRecord { + self.selection = EditorSelection::collapsed(inserted_end); + self.emit_selection_changed(cx); + let paragraph_delta = self.document.paragraphs.len() as isize - before_document.paragraphs.len() as isize; + let after_count = before_span + .paragraphs + .len() + .saturating_add_signed(paragraph_delta) + .min( + self + .document + .paragraphs + .len() + .saturating_sub(before_span.start_paragraph), + ); + let after_span = capture_document_span(&self.document, before_span.start_paragraph..before_span.start_paragraph + after_count); + let semantic_commands = vec![SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: before_span.start_paragraph, + byte: 0, + }), + before: before_span, + after: after_span, + }]; + self.record_local_history(EditRecord { before_selection, before_generation, after_selection: self.selection.clone(), @@ -286,21 +318,10 @@ impl RichTextEditor { inserted_range: inserted_start..inserted_end, fragment: drag.fragment, }], - canonical_operations: vec![CanonicalOperation::ReplaceParagraphSpan { - start_paragraph: self.identity_map.paragraph_id(inserted_start.paragraph), - before: capture_document_span( - &self.document, - inserted_start.paragraph..(inserted_start.paragraph + 1).min(self.document.paragraphs.len()), - ), - after: capture_document_span( - &self.document, - inserted_start.paragraph..(inserted_end.paragraph + 1).min(self.document.paragraphs.len()), - ), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.after_text_mutation(cx); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); self.clear_drop_preview(); } @@ -378,7 +399,7 @@ impl RichTextEditor { let Some(bounds) = layout.bounds else { return; }; - let Some(caret) = caret_bounds(&layout, self.selection.head, bounds.origin) else { + let Some(caret) = caret_bounds(&layout, self.selection.head, self.selection.head_gravity, bounds.origin) else { return; }; scroll_rect_into_view(&self.scroll_handle, caret, px(4.0)); @@ -433,6 +454,7 @@ impl RichTextEditor { && editor.selection.head != head { editor.selection.head = head; + editor.emit_selection_changed(cx); } cx.notify(); true diff --git a/crates/gpui-flowtext/src/rich_text/editor/movement_core.rs b/crates/gpui-flowtext/src/rich_text/editor/movement_core.rs index 92ae74f0..3b76e46d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/movement_core.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/movement_core.rs @@ -1,13 +1,13 @@ #[hotpath::measure_all] impl RichTextEditor { - fn move_to_offset(&mut self, new_head: DocumentOffset, extend: bool, cx: &mut Context) { - let anchor = if extend { self.selection.anchor } else { new_head }; - let selection = EditorSelection { anchor, head: new_head }; - if self.selection == selection { + fn move_to_offset(&mut self, new_head: DocumentOffset, head_affinity: SelectionAffinity, head_gravity: VisualGravity, extend: bool, cx: &mut Context) { + let selection = self.selection.moved(new_head, head_affinity, head_gravity, extend); + if self.selection.same_positions(&selection) { self.goal_x = None; return; } self.selection = selection; + self.emit_selection_changed(cx); self.goal_x = None; self.scroll_head_into_view(); self.reset_caret_blink(cx); @@ -39,7 +39,7 @@ impl RichTextEditor { let new_offset = clamp_scroll_offset(&self.scroll_handle, point(old_offset.x, old_offset.y + signed_delta)); self.scroll_handle.set_offset(new_offset); - let Some(caret) = caret_bounds(&layout, head, bounds.origin) else { + let Some(caret) = caret_bounds(&layout, head, self.selection.head_gravity, bounds.origin) else { cx.notify(); return; }; @@ -50,7 +50,7 @@ impl RichTextEditor { let target = self .hit_test_cached_position(point(caret.origin.x, target_y)) .unwrap_or_else(|| layout.hit_test(point(caret.origin.x, target_y))); - self.move_to_offset(target, extend, cx); + self.move_to_offset(target, SelectionAffinity::Neutral, VisualGravity::Neutral, extend, cx); } pub(super) fn after_text_mutation(&mut self, cx: &mut Context) { diff --git a/crates/gpui-flowtext/src/rich_text/editor/object_assets.rs b/crates/gpui-flowtext/src/rich_text/editor/object_assets.rs index d4e4c77a..b932c16d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/object_assets.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/object_assets.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -fn reserved_object_frame(document: &Document, row_size: Size, selected: bool) -> gpui::Div { +fn reserved_object_frame(document: &DocumentProjection, row_size: Size, selected: bool) -> gpui::Div { let object_height = (row_size.height - document.theme.paragraph_after).max(px(1.0)); let object_width = (row_size.width - document.theme.pageless_inset_x * 2.0).max(px(1.0)); div() @@ -16,15 +16,20 @@ fn reserved_object_frame(document: &Document, row_size: Size, selected: } #[hotpath::measure] -fn image_object_frame(document: &Document, image: &ImageBlock, asset: &AssetRecord, row_size: Size, selected: bool) -> gpui::Div { +fn image_object_frame(document: &DocumentProjection, image: &ImageBlock, asset: &AssetRecord, row_size: Size, selected: bool) -> gpui::Div { let available_width = (row_size.width - document.theme.pageless_inset_x * 2.0).max(px(1.0)); let intrinsic = image_asset_intrinsic_size(asset); - let object_width = match image.sizing { - ImageSizing::Fixed { width_px, .. } => px(width_px as f32).min(available_width), - ImageSizing::FitWidth => available_width, - ImageSizing::Intrinsic => intrinsic - .map(|(width, _)| width.min(available_width)) - .unwrap_or(available_width), + let loading = asset.is_loading_placeholder(); + let object_width = if loading { + px(IMAGE_LOADING_PLACEHOLDER_WIDTH_PX).min(available_width) + } else { + match image.sizing { + ImageSizing::Fixed { width_px, .. } => px(width_px as f32).min(available_width), + ImageSizing::FitWidth => available_width, + ImageSizing::Intrinsic => intrinsic + .map(|(width, _)| width.min(available_width)) + .unwrap_or(available_width), + } }; let object_height = (row_size.height - document.theme.paragraph_after).max(px(1.0)); let left_margin = document.theme.pageless_inset_x @@ -41,13 +46,22 @@ fn image_object_frame(document: &Document, image: &ImageBlock, asset: &AssetReco .mr(document.theme.pageless_inset_x) .mb(document.theme.paragraph_after) .overflow_hidden() - .bg(rgb(0xffffff)) + .bg(if loading { rgb(0xf3f4f6) } else { rgb(0xffffff) }) .border_1() - .border_color(if selected { rgb(0x0969da) } else { rgb(0xffffff) }) + .border_color(if selected { + rgb(0x0969da) + } else if loading { + rgb(0xd0d7de) + } else { + rgb(0xffffff) + }) } #[hotpath::measure] fn image_asset_intrinsic_size(asset: &AssetRecord) -> Option<(Pixels, Pixels)> { + if asset.is_loading_placeholder() { + return None; + } let size = imagesize::blob_size(asset.bytes.as_ref()).ok()?; if size.width == 0 || size.height == 0 { return None; @@ -68,14 +82,13 @@ fn image_asset_from_path(path: &Path) -> Option<(AssetRecord, SharedString)> { .file_name() .map(|name| name.to_string_lossy().to_string()); let alt_text: SharedString = original_name.clone().unwrap_or_default().into(); - let mut hasher = DefaultHasher::new(); - bytes.hash(&mut hasher); + let content_hash = AssetRecord::stable_content_hash(&bytes); Some(( AssetRecord { id: AssetId(uuid::Uuid::new_v4().as_u128()), mime_type: format.mime_type().into(), original_name: original_name.map(Into::into), - content_hash: hasher.finish(), + content_hash, bytes: Arc::new(bytes), }, alt_text, @@ -85,14 +98,13 @@ fn image_asset_from_path(path: &Path) -> Option<(AssetRecord, SharedString)> { #[hotpath::measure] fn image_asset_from_image(image: Image) -> (AssetRecord, SharedString) { let asset_id = AssetId(uuid::Uuid::new_v4().as_u128()); - let mut hasher = DefaultHasher::new(); - image.bytes.hash(&mut hasher); + let content_hash = AssetRecord::stable_content_hash(&image.bytes); ( AssetRecord { id: asset_id, mime_type: image.format.mime_type().into(), original_name: None, - content_hash: hasher.finish(), + content_hash, bytes: Arc::new(image.bytes), }, "Pasted image".into(), diff --git a/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs b/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs index 93d85a77..404fbe3f 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/object_selection.rs @@ -27,6 +27,28 @@ impl RichTextEditor { cx.notify(); } + #[cfg(test)] + pub(super) fn select_equation_block_for_test(&mut self, block_ix: usize, cx: &mut Context) { + self.select_block(BlockSelection::Equation(block_ix), cx); + } + + #[cfg(test)] + pub(super) fn select_image_block_for_test(&mut self, block_ix: usize, cx: &mut Context) { + self.select_block(BlockSelection::Image(block_ix), cx); + } + + #[cfg(test)] + pub(super) fn select_table_cell_for_test(&mut self, block_ix: usize, row_ix: usize, cell_ix: usize, cx: &mut Context) { + self.select_block(BlockSelection::TableCell { block_ix, row_ix, cell_ix }, cx); + } + + #[cfg(test)] + pub(super) fn set_text_selection_for_test(&mut self, anchor: DocumentOffset, head: DocumentOffset, cx: &mut Context) { + self.selected_block = None; + self.selection = EditorSelection::range(anchor, head); + cx.notify(); + } + fn select_block_from_click( &mut self, block_ix: usize, @@ -237,7 +259,26 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + let semantic_commands = if let Some(table_id) = self.semantic_block_id(drag.block_ix) { + after + .column_widths + .iter() + .enumerate() + .filter(|(column_ix, width)| drag.before.column_widths.get(*column_ix) != Some(*width)) + .map(|(column_ix, width)| SemanticEditCommand::SetTableColumnWidth { + table: table_id, + column_ix, + width: input_table_column_width_from_table_column_width(width), + }) + .collect::>() + } else { + eprintln!( + "skipping table column resize semantic command because projection block {} has no durable id", + drag.block_ix + ); + Vec::new() + }; + self.record_local_history(EditRecord { before_selection: self.selection.clone(), before_generation, after_selection: self.selection.clone(), @@ -247,13 +288,10 @@ impl RichTextEditor { before: Block::Table(drag.before), after: Block::Table(after), }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(drag.block_ix), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); true } @@ -431,6 +469,17 @@ impl RichTextEditor { if object_indices.is_empty() { return false; } + let mut object_delete_commands = Vec::with_capacity(object_indices.len()); + for block_ix in object_indices.iter().copied() { + if let Some(block) = self.semantic_block_id(block_ix) { + object_delete_commands.push(SemanticEditCommand::DeleteBlock { block }); + } else { + eprintln!("skipping selected-object deletion semantic command because projection block {block_ix} has no durable id"); + } + } + let before_paragraph_count = self.document.paragraphs.len(); + let before_range = range.start.paragraph..(range.end.paragraph + 1).min(before_paragraph_count); + let before_span = capture_document_span(&self.document, before_range); let before_document = self.document.clone(); let before_selection = self.selection.clone(); { @@ -446,23 +495,46 @@ impl RichTextEditor { } self.delete_selection_internal(); let after_document = self.document.clone(); + let paragraph_delta = self.document.paragraphs.len() as isize - before_paragraph_count as isize; + let after_count = before_span + .paragraphs + .len() + .saturating_add_signed(paragraph_delta) + .min( + self + .document + .paragraphs + .len() + .saturating_sub(before_span.start_paragraph), + ); + let after_span = capture_document_span(&self.document, before_span.start_paragraph..before_span.start_paragraph + after_count); let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + let mut semantic_commands = object_delete_commands; + if before_span != after_span { + semantic_commands.push(SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: before_span.start_paragraph, + byte: 0, + }), + before: before_span, + after: after_span, + }); + } + self.record_local_history(EditRecord { before_selection, before_generation, after_selection: self.selection.clone(), after_generation, - operations: vec![EditOperation::ReplaceDocument { + operations: vec![EditOperation::RestoreProjectionSnapshot { before: Box::new(before_document), after: Box::new(after_document), }], - canonical_operations: vec![CanonicalOperation::ReplaceDocument], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); true } @@ -483,6 +555,7 @@ impl RichTextEditor { if block_ix >= self.document.blocks.len() { return false; } + let block_id = self.semantic_block_id(block_ix); let blocks = Arc::make_mut(&mut self.document.blocks); if matches!(blocks.get(block_ix), Some(Block::Paragraph(_))) { return false; @@ -493,21 +566,25 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + let semantic_commands = block_id.map_or_else( + || { + eprintln!("skipping selected-block deletion semantic command because projection block {block_ix} has no durable id"); + Vec::new() + }, + |block| vec![SemanticEditCommand::DeleteBlock { block }], + ); + self.record_local_history(EditRecord { before_selection: before_selection.clone(), before_generation, after_selection: self.selection.clone(), after_generation, operations: vec![EditOperation::DeleteBlock { block_ix, block }], - canonical_operations: vec![CanonicalOperation::DeleteBlock { - block: self.identity_map.block_id(block_ix).unwrap_or(BlockId(0)), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.clear_layout_work_caches(); self.item_sizes_cache = None; self.paragraph_height_cache_revision = self.paragraph_height_cache_revision.wrapping_add(1); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); true } @@ -544,10 +621,10 @@ impl RichTextEditor { } pub(super) fn drag_source_selection(&self) -> Option { - self.active_text_drag.as_ref().map(|drag| EditorSelection { - anchor: drag.source_range.start, - head: drag.source_range.end, - }) + self + .active_text_drag + .as_ref() + .map(|drag| EditorSelection::range(drag.source_range.start, drag.source_range.end)) } pub(super) fn caret_paint_width(&self) -> Pixels { diff --git a/crates/gpui-flowtext/src/rich_text/editor/paste.rs b/crates/gpui-flowtext/src/rich_text/editor/paste.rs index 3be8aca1..478cbd40 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/paste.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/paste.rs @@ -4,7 +4,10 @@ impl RichTextEditor { if !self.selection.is_caret() || self.selected_block.is_some() || text.is_empty() || text.contains('\r') || text.contains('\n') { return false; } - let paragraph_style = self.document.paragraphs[self.selection.head.paragraph].style; + let Some(paragraph) = self.document.paragraphs.get(self.selection.head.paragraph) else { + return false; + }; + let paragraph_style = paragraph.style; let fragment = RichClipboardFragment { format: RICH_TEXT_CLIPBOARD_FORMAT.to_string(), paragraphs: vec![InputParagraph { @@ -20,7 +23,7 @@ impl RichTextEditor { self.insert_rich_fragment_paste_at_caret(&fragment, cx) } - fn insert_rich_fragment_paste_at_caret(&mut self, fragment: &RichClipboardFragment, cx: &mut Context) -> bool { + pub(super) fn insert_rich_fragment_paste_at_caret(&mut self, fragment: &RichClipboardFragment, cx: &mut Context) -> bool { if !self.selection.is_caret() || self.selected_block.is_some() || !fragment.blocks.is_empty() @@ -29,12 +32,6 @@ impl RichTextEditor { { return false; } - let Some(paragraph_id) = self - .identity_map - .paragraph_id(self.selection.head.paragraph) - else { - return false; - }; let paragraph = &fragment.paragraphs[0]; if paragraph.runs.iter().all(|run| run.text.is_empty()) { return true; @@ -45,12 +42,20 @@ impl RichTextEditor { let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); let offset = self.selection.head; + let before_span = capture_document_span(&self.document, offset.paragraph..offset.paragraph + 1); let inserted_end = insert_rich_fragment_at(&mut self.document, offset, fragment); - self.selection = EditorSelection { - anchor: inserted_end, - head: inserted_end, - }; - self.undo_stack.push(EditRecord { + let after_span = capture_document_span(&self.document, offset.paragraph..inserted_end.paragraph + 1); + self.selection = EditorSelection::collapsed(inserted_end); + self.emit_selection_changed(cx); + let semantic_commands = vec![SemanticEditCommand::ReplaceParagraphSpan { + start: Some(DocumentOffset { + paragraph: offset.paragraph, + byte: 0, + }), + before: before_span.clone(), + after: after_span.clone(), + }]; + self.record_local_history(EditRecord { before_selection, before_generation, after_selection: self.selection.clone(), @@ -60,12 +65,11 @@ impl RichTextEditor { inserted_end, fragment: fragment.clone(), }], - canonical_operations: canonical_insert_text_operations(paragraph_id, offset.byte, paragraph), + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.layout_invalidation_hint = Some(offset.paragraph..offset.paragraph + 1); self.after_text_mutation(cx); - self.mark_document_changed_with_reconcile(after_generation, false, cx); + self.mark_document_changed_with_ops(after_generation, false, Some(&semantic_commands), cx); true } @@ -145,7 +149,7 @@ impl RichTextEditor { fn insert_paragraphs_into_selected_table_cell(&mut self, paragraphs: &[InputParagraph], cx: &mut Context) -> bool { let Some(BlockSelection::TableCell { - block_ix: _, + block_ix, row_ix, cell_ix, }) = self.selected_block @@ -158,14 +162,7 @@ impl RichTextEditor { let current_paragraph_ix = self.table_cell_block_ix; let caret = self.table_cell_caret; let mut new_caret = None; - self.edit_selected_table(cx, |table| { - let Some(cell) = table - .rows - .get_mut(row_ix) - .and_then(|row| row.cells.get_mut(cell_ix)) - else { - return; - }; + self.edit_table_cell(block_ix, row_ix, cell_ix, cx, |cell| { new_caret = insert_table_cell_paragraphs_at(cell, current_paragraph_ix, caret, paragraphs); }); if let Some((paragraph_ix, byte)) = new_caret { diff --git a/crates/gpui-flowtext/src/rich_text/editor/platform.rs b/crates/gpui-flowtext/src/rich_text/editor/platform.rs index e83c2827..3fc1a1b8 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/platform.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/platform.rs @@ -19,3 +19,195 @@ fn windows_apply_capslock(text: &str) -> String { } } +#[hotpath::measure_all] +impl RichTextEditor { + pub fn ime_composition_active(&self) -> bool { + self.ime_marked_range.is_some() + } + + fn selection_utf16_range(&self) -> Range { + let text = full_document_text(&self.document); + let anchor = byte_to_utf16( + &text, + global_byte(&self.document, clamp_offset_to_document(&self.document, self.selection.anchor)), + ); + let head = byte_to_utf16( + &text, + global_byte(&self.document, clamp_offset_to_document(&self.document, self.selection.head)), + ); + anchor.min(head)..anchor.max(head) + } + + fn utf16_range_to_document_offsets(&self, range: Range) -> Range { + let text = full_document_text(&self.document); + let start = utf16_to_byte(&text, range.start); + let end = utf16_to_byte(&text, range.end).max(start); + global_to_document_offset(&self.document, start)..global_to_document_offset(&self.document, end) + } + + fn replace_text_for_utf16_range(&mut self, range: Range, text: &str, cx: &mut Context) { + if self.insert_text_into_selected_object_text(text, cx) { + return; + } + let offsets = self.utf16_range_to_document_offsets(range); + let selection = EditorSelection::range(offsets.start, offsets.end); + let before_selection = self.selection.clone(); + self.selection = selection; + if text.is_empty() { + if self.selection.is_caret() { + if self.selection != before_selection { + self.emit_selection_changed(cx); + self.reset_caret_blink(cx); + cx.notify(); + } + return; + } + self.apply_document_edit(cx, |editor, cx| { + editor.delete_selection_internal(); + editor.after_text_mutation(cx); + }); + } else { + self.insert_text_command(text, cx); + } + if self.selection != before_selection { + self.emit_selection_changed(cx); + } + } + + #[cfg(test)] + pub(super) fn replace_selected_text_from_platform_for_test(&mut self, text: &str, cx: &mut Context) { + self.replace_text_for_utf16_range(self.selection_utf16_range(), text, cx); + } +} + +#[hotpath::measure_all] +impl EntityInputHandler for RichTextEditor { + fn text_for_range( + &mut self, + range: Range, + adjusted_range: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + let text = full_document_text(&self.document); + let start = utf16_to_byte(&text, range.start); + let end = utf16_to_byte(&text, range.end).max(start); + *adjusted_range = Some(byte_range_to_utf16(&text, start..end)); + Some(document_text_slice(&self.document, start..end)) + } + + fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context) -> Option { + Some(UTF16Selection { + range: self.selection_utf16_range(), + reversed: self.selection.anchor > self.selection.head, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self.ime_marked_range.clone() + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + if self.ime_marked_range.take().is_some() { + cx.notify(); + } + } + + fn replace_text_in_range(&mut self, range: Option>, text: &str, _: &mut Window, cx: &mut Context) { + let range = range + .or_else(|| self.ime_marked_range.clone()) + .unwrap_or_else(|| self.selection_utf16_range()); + self.ime_marked_range = None; + self.replace_text_for_utf16_range(range, text, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range: Option>, + new_text: &str, + new_selected_range: Option>, + _: &mut Window, + cx: &mut Context, + ) { + let range = range + .or_else(|| self.ime_marked_range.clone()) + .unwrap_or_else(|| self.selection_utf16_range()); + let mark_start = range.start; + self.replace_text_for_utf16_range(range, new_text, cx); + if new_text.is_empty() { + self.ime_marked_range = None; + return; + } + let marked_range = mark_start..mark_start + new_text.encode_utf16().count(); + self.ime_marked_range = Some(marked_range.clone()); + let selection_range = new_selected_range + .map(|range| marked_range.start + range.start..marked_range.start + range.end) + .unwrap_or_else(|| marked_range.end..marked_range.end); + let offsets = self.utf16_range_to_document_offsets(selection_range); + let selection = EditorSelection::range(offsets.start, offsets.end); + if self.selection != selection { + self.selection = selection; + self.emit_selection_changed(cx); + self.reset_caret_blink(cx); + cx.notify(); + } + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + _: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + let offsets = self.utf16_range_to_document_offsets(range_utf16); + let layout = self.layout_for_offset(offsets.start)?; + let origin = layout.bounds?.origin; + caret_bounds(&layout, offsets.start, VisualGravity::Neutral, origin) + } + + fn character_index_for_point(&mut self, point: Point, window: &mut Window, cx: &mut Context) -> Option { + let offset = self.hit_test_document_position(point, window, cx); + let text = full_document_text(&self.document); + Some(byte_to_utf16( + &text, + global_byte(&self.document, clamp_offset_to_document(&self.document, offset)), + )) + } +} + +#[hotpath::measure] +fn byte_range_to_utf16(text: &str, range: Range) -> Range { + byte_to_utf16(text, range.start)..byte_to_utf16(text, range.end) +} + +#[hotpath::measure] +fn byte_to_utf16(text: &str, byte: usize) -> usize { + let byte = previous_char_boundary(text, byte); + text[..byte].encode_utf16().count() +} + +#[hotpath::measure] +fn utf16_to_byte(text: &str, target: usize) -> usize { + let mut utf16 = 0usize; + for (byte, ch) in text.char_indices() { + if target <= utf16 { + return byte; + } + let next = utf16 + ch.len_utf16(); + if target < next { + return byte; + } + utf16 = next; + } + text.len() +} + +#[hotpath::measure] +fn previous_char_boundary(text: &str, byte: usize) -> usize { + let mut byte = byte.min(text.len()); + while !text.is_char_boundary(byte) { + byte = byte.saturating_sub(1); + } + byte +} diff --git a/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs b/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs new file mode 100644 index 00000000..be3bc528 --- /dev/null +++ b/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs @@ -0,0 +1,431 @@ +#[hotpath::measure_all] +impl RichTextEditor { + // Asset bytes are transferred out-of-band; remote document content is applied + // by replacing the Loro projection snapshot, not by feeding document patches. + pub fn apply_synced_asset_records(&mut self, asset_records: &[(AssetId, AssetRecord)], cx: &mut Context) { + if asset_records.is_empty() { + return; + } + self.suppress_command_capture = self.suppress_command_capture.saturating_add(1); + for (id, record) in asset_records { + self.document.assets.assets.insert(*id, record.clone()); + } + self.suppress_command_capture = self.suppress_command_capture.saturating_sub(1); + let generation = self.next_edit_generation; + self.next_edit_generation = self.next_edit_generation.wrapping_add(1); + self.mark_document_changed_with_ops(generation, false, None, cx); + self.after_formatting_mutation(cx); + } + + // Retained for local derived UI-diff helpers; network collaboration applies + // Loro projection snapshots instead. + pub fn apply_projection_patches(&mut self, patches: &[ProjectionPatch], cx: &mut Context) { + self.apply_projection_patches_at_frontier(patches, self.document.frontier.clone(), cx); + } + + pub fn apply_projection_patches_at_frontier(&mut self, patches: &[ProjectionPatch], frontier: Vec, cx: &mut Context) { + if patches.is_empty() { + self.document.frontier = frontier; + return; + } + self.suppress_command_capture = self.suppress_command_capture.saturating_add(1); + let mut invalidation: Option> = None; + for patch in patches { + self.apply_one_projection_patch(patch, &mut invalidation); + } + self.document.frontier = frontier; + self.suppress_command_capture = self.suppress_command_capture.saturating_sub(1); + self.identity_map.reconcile(&self.document); + self.layout_invalidation_hint = invalidation; + let generation = self.next_edit_generation; + self.next_edit_generation = self.next_edit_generation.wrapping_add(1); + self.mark_document_changed_with_ops(generation, false, None, cx); + // Remote collaboration patches should update this editor in place, but + // should not scroll the viewport as if the local user typed the change. + self.after_formatting_mutation(cx); + self.layout_invalidation_hint = None; + } + + pub fn projection_apply_deferred(&self) -> bool { + self.selecting + || self.active_text_drag.is_some() + || self.image_resize_drag.is_some() + || self.table_column_resize_drag.is_some() + || self.ime_composition_active() + } + + fn apply_one_projection_patch(&mut self, patch: &ProjectionPatch, invalidation: &mut Option>) { + match patch { + ProjectionPatch::ParagraphText { row, new, delta_utf8 } => { + self.remap_object_text_selection_for_delta(*row, delta_utf8); + if let Some(paragraph_ix) = self.paragraph_ix_for_block(*row) { + remap_selection_for_text_delta(&mut self.selection, paragraph_ix, delta_utf8); + replace_paragraph_content(&mut self.document, paragraph_ix, new); + clamp_selection_to_document(&self.document, &mut self.selection); + extend_invalidation(invalidation, paragraph_ix..paragraph_ix + 1); + } + }, + ProjectionPatch::ParagraphStyle { row, style } => { + if let Some(paragraph_ix) = self.paragraph_ix_for_block(*row) + && let Some(paragraph) = paragraphs_mut(&mut self.document).get_mut(paragraph_ix) + { + paragraph.style = *style; + bump_paragraph_version(paragraph); + update_paragraph_block(&mut self.document, paragraph_ix); + rebuild_document_sections(&mut self.document); + extend_invalidation(invalidation, paragraph_ix..paragraph_ix + 1); + } + }, + ProjectionPatch::ParagraphRuns { row, runs } => { + if let Some(paragraph_ix) = self.paragraph_ix_for_block(*row) + && let Some(paragraph) = paragraphs_mut(&mut self.document).get_mut(paragraph_ix) + { + paragraph.runs.clone_from(runs); + bump_paragraph_version(paragraph); + update_paragraph_block(&mut self.document, paragraph_ix); + rebuild_document_sections(&mut self.document); + extend_invalidation(invalidation, paragraph_ix..paragraph_ix + 1); + } + }, + ProjectionPatch::ReplaceObjectBlock { row, block } => { + let mut blocks = projection_structural_blocks_from_document(&self.document); + if *row < blocks.len() { + if selected_block_in_range(self.selected_block, *row..row.saturating_add(1)) { + self.clear_remote_object_editing_state(); + } + blocks[*row] = block.clone(); + rebuild_document_from_projection_structural_blocks(&mut self.document, blocks); + clamp_selection_to_document(&self.document, &mut self.selection); + extend_invalidation(invalidation, 0..self.document.paragraphs.len()); + } + }, + ProjectionPatch::InsertBlocks { row, blocks: inserted } => { + let mut blocks = projection_structural_blocks_from_document(&self.document); + let row = (*row).min(blocks.len()); + if selected_block_ix(self.selected_block).is_some_and(|block_ix| block_ix >= row) { + self.clear_remote_object_editing_state(); + } + blocks.splice(row..row, inserted.iter().cloned()); + rebuild_document_from_projection_structural_blocks(&mut self.document, blocks); + clamp_selection_to_document(&self.document, &mut self.selection); + extend_invalidation(invalidation, 0..self.document.paragraphs.len()); + }, + ProjectionPatch::DeleteBlocks { row, count } => { + let mut blocks = projection_structural_blocks_from_document(&self.document); + let start = (*row).min(blocks.len()); + let end = start.saturating_add(*count).min(blocks.len()); + if selected_block_ix(self.selected_block).is_some_and(|block_ix| block_ix >= start) { + self.clear_remote_object_editing_state(); + } + blocks.drain(start..end); + rebuild_document_from_projection_structural_blocks(&mut self.document, blocks); + clamp_selection_to_document(&self.document, &mut self.selection); + extend_invalidation(invalidation, 0..self.document.paragraphs.len()); + }, + ProjectionPatch::MoveBlock { from, to } => { + let mut blocks = projection_structural_blocks_from_document(&self.document); + if *from < blocks.len() { + let first = (*from).min(*to); + let last = (*from).max(*to); + if selected_block_ix(self.selected_block).is_some_and(|block_ix| (first..=last).contains(&block_ix)) { + self.clear_remote_object_editing_state(); + } + let block = blocks.remove(*from); + blocks.insert((*to).min(blocks.len()), block); + rebuild_document_from_projection_structural_blocks(&mut self.document, blocks); + clamp_selection_to_document(&self.document, &mut self.selection); + extend_invalidation(invalidation, 0..self.document.paragraphs.len()); + } + }, + ProjectionPatch::AssetArrived { id, record } => { + self.document.assets.assets.insert(*id, record.clone()); + }, + } + } + + fn clear_remote_object_editing_state(&mut self) { + self.selected_block = None; + self.image_resize_drag = None; + self.table_column_resize_drag = None; + self.table_cell_block_ix = 0; + self.table_cell_anchor = 0; + self.table_cell_caret = 0; + self.equation_source_anchor = 0; + self.equation_source_caret = 0; + } + + fn remap_object_text_selection_for_delta(&mut self, row: usize, delta: &[ProjectionTextDelta]) { + match self.selected_block { + Some(BlockSelection::TableCell { block_ix, .. }) if block_ix == row => { + self.table_cell_anchor = remap_byte(self.table_cell_anchor, delta); + self.table_cell_caret = remap_byte(self.table_cell_caret, delta); + }, + Some(BlockSelection::Equation(block_ix)) if block_ix == row => { + self.equation_source_anchor = remap_byte(self.equation_source_anchor, delta); + self.equation_source_caret = remap_byte(self.equation_source_caret, delta); + }, + Some(BlockSelection::Image(_) | BlockSelection::Equation(_) | BlockSelection::Table(_) | BlockSelection::TableCell { .. }) | None => {}, + } + } +} + +pub fn apply_projection_patches(document: &mut DocumentProjection, patches: &[ProjectionPatch]) { + for patch in patches { + match patch { + ProjectionPatch::ParagraphText { row, new, .. } => { + if let Some(paragraph_ix) = paragraph_ix_for_block_row(document, *row) { + replace_paragraph_content(document, paragraph_ix, new); + } + }, + ProjectionPatch::ParagraphStyle { row, style } => { + if let Some(paragraph_ix) = paragraph_ix_for_block_row(document, *row) + && let Some(paragraph) = paragraphs_mut(document).get_mut(paragraph_ix) + { + paragraph.style = *style; + bump_paragraph_version(paragraph); + update_paragraph_block(document, paragraph_ix); + rebuild_document_sections(document); + } + }, + ProjectionPatch::ParagraphRuns { row, runs } => { + if let Some(paragraph_ix) = paragraph_ix_for_block_row(document, *row) + && let Some(paragraph) = paragraphs_mut(document).get_mut(paragraph_ix) + { + paragraph.runs.clone_from(runs); + bump_paragraph_version(paragraph); + update_paragraph_block(document, paragraph_ix); + rebuild_document_sections(document); + } + }, + ProjectionPatch::ReplaceObjectBlock { row, block } => { + let mut blocks = projection_structural_blocks_from_document(document); + if *row < blocks.len() { + blocks[*row] = block.clone(); + rebuild_document_from_projection_structural_blocks(document, blocks); + } + }, + ProjectionPatch::InsertBlocks { row, blocks: inserted } => { + let mut blocks = projection_structural_blocks_from_document(document); + let row = (*row).min(blocks.len()); + blocks.splice(row..row, inserted.iter().cloned()); + rebuild_document_from_projection_structural_blocks(document, blocks); + }, + ProjectionPatch::DeleteBlocks { row, count } => { + let mut blocks = projection_structural_blocks_from_document(document); + let start = (*row).min(blocks.len()); + let end = start.saturating_add(*count).min(blocks.len()); + blocks.drain(start..end); + rebuild_document_from_projection_structural_blocks(document, blocks); + }, + ProjectionPatch::MoveBlock { from, to } => { + let mut blocks = projection_structural_blocks_from_document(document); + if *from < blocks.len() { + let block = blocks.remove(*from); + blocks.insert((*to).min(blocks.len()), block); + rebuild_document_from_projection_structural_blocks(document, blocks); + } + }, + ProjectionPatch::AssetArrived { id, record } => { + document.assets.assets.insert(*id, record.clone()); + }, + } + } +} + +fn paragraph_ix_for_block_row(document: &DocumentProjection, row: usize) -> Option { + matches!(document.blocks.get(row), Some(Block::Paragraph(_))).then(|| { + document + .blocks + .iter() + .take(row) + .filter(|block| matches!(block, Block::Paragraph(_))) + .count() + }) +} + +#[hotpath::measure] +fn selected_block_ix(selection: Option) -> Option { + match selection { + Some(BlockSelection::Image(block_ix) + | BlockSelection::Equation(block_ix) + | BlockSelection::Table(block_ix) + | BlockSelection::TableCell { block_ix, .. }) => Some(block_ix), + None => None, + } +} + +#[hotpath::measure] +fn selected_block_in_range(selection: Option, range: Range) -> bool { + selected_block_ix(selection).is_some_and(|block_ix| range.contains(&block_ix)) +} + +#[hotpath::measure] +fn replace_paragraph_content(document: &mut DocumentProjection, paragraph_ix: usize, paragraph: &InputParagraph) { + if paragraph_ix >= document.paragraphs.len() { + return; + } + let text = input_paragraph_text(paragraph); + let byte_range = paragraph_byte_range(document, paragraph_ix); + document.text.delete(byte_range.clone()); + document.text.insert(byte_range.start, &text); + let old_style = document.paragraphs[paragraph_ix].style; + let mut replacement = paragraph_from_input_paragraph(paragraph); + replacement.version = document.paragraphs[paragraph_ix].version.wrapping_add(1); + replacement.byte_range = byte_range.clone(); + paragraphs_mut(document)[paragraph_ix] = replacement; + // Single in-place paragraph update (count unchanged): shift the offset index + // and the block in place. The section outline can only change if this + // paragraph's style changed. + update_paragraph_offsets_after_len_change(document, paragraph_ix); + if old_style != paragraph.style { + rebuild_document_sections(document); + } +} + +#[hotpath::measure] +fn projection_structural_blocks_from_document(document: &DocumentProjection) -> Vec { + let mut paragraph_ix = 0; + document + .blocks + .iter() + .enumerate() + .map(|(block_ix, block)| match block { + Block::Paragraph(paragraph) => { + let input = input_paragraph_from_document_paragraph(document, paragraph_ix, paragraph); + let structural = ProjectionStructuralBlock { + block_id: document.ids.block_ids.get(block_ix).copied().unwrap_or_else(new_block_id), + paragraph_id: Some( + document + .ids + .paragraph_ids + .get(paragraph_ix) + .copied() + .unwrap_or_else(new_paragraph_id), + ), + block: InputBlock::Paragraph(input), + }; + paragraph_ix += 1; + structural + }, + Block::Image(_) | Block::Equation(_) | Block::Table(_) => ProjectionStructuralBlock { + block_id: document.ids.block_ids.get(block_ix).copied().unwrap_or_else(new_block_id), + paragraph_id: None, + block: input_block_from_block(block), + }, + }) + .collect() +} + +#[hotpath::measure] +fn input_paragraph_from_document_paragraph(document: &DocumentProjection, paragraph_ix: usize, paragraph: &Paragraph) -> InputParagraph { + let text = paragraph_text(document, paragraph_ix); + let mut byte = 0; + InputParagraph { + style: paragraph.style, + runs: paragraph + .runs + .iter() + .map(|run| { + let start = byte; + let end = (start + run.len).min(text.len()); + byte = end; + InputRun { + text: text.get(start..end).unwrap_or("").to_string(), + styles: run.styles, + } + }) + .collect(), + } +} + +#[hotpath::measure] +fn rebuild_document_from_projection_structural_blocks(document: &mut DocumentProjection, blocks: Vec) { + let assets = document.assets.clone(); + let theme = document.theme.clone(); + let document_id = document.ids.document_id; + let input_blocks = blocks + .iter() + .map(|block| block.block.clone()) + .collect::>(); + let block_ids = blocks.iter().map(|block| block.block_id).collect::>(); + let paragraph_ids = blocks + .iter() + .filter_map(|block| match &block.block { + InputBlock::Paragraph(_) => Some(block.paragraph_id.unwrap_or_else(new_paragraph_id)), + InputBlock::Image(_) | InputBlock::Equation(_) | InputBlock::Table(_) => None, + }) + .collect::>(); + let mut rebuilt = document_from_input_blocks(theme, input_blocks); + rebuilt.assets = assets; + rebuilt.ids.document_id = document_id; + rebuilt.ids.block_ids = block_ids; + rebuilt.ids.paragraph_ids = paragraph_ids; + debug_assert_eq!(rebuilt.ids.block_ids.len(), rebuilt.blocks.len()); + debug_assert_eq!(rebuilt.ids.paragraph_ids.len(), rebuilt.paragraphs.len()); + rebuild_document_sections(&mut rebuilt); + *document = rebuilt; +} + +#[hotpath::measure] +fn remap_selection_for_text_delta(selection: &mut EditorSelection, paragraph_ix: usize, delta: &[ProjectionTextDelta]) { + if selection.anchor.paragraph == paragraph_ix { + selection.anchor.byte = remap_byte(selection.anchor.byte, delta); + } + if selection.head.paragraph == paragraph_ix { + selection.head.byte = remap_byte(selection.head.byte, delta); + } +} + +#[hotpath::measure] +fn remap_byte(byte: usize, delta: &[ProjectionTextDelta]) -> usize { + let mut old = 0usize; + let mut new = 0usize; + for item in delta { + match *item { + ProjectionTextDelta::Retain(len) => { + if byte <= old + len { + return new + (byte - old); + } + old += len; + new += len; + }, + ProjectionTextDelta::Insert(len) => { + new += len; + }, + ProjectionTextDelta::Delete(len) => { + if byte <= old + len { + return new; + } + old += len; + }, + } + } + new + byte.saturating_sub(old) +} + +#[hotpath::measure] +fn clamp_selection_to_document(document: &DocumentProjection, selection: &mut EditorSelection) { + selection.anchor = clamp_offset_to_document(document, selection.anchor); + selection.head = clamp_offset_to_document(document, selection.head); +} + +#[hotpath::measure] +fn clamp_offset_to_document(document: &DocumentProjection, offset: DocumentOffset) -> DocumentOffset { + let paragraph = offset.paragraph.min(document.paragraphs.len().saturating_sub(1)); + let byte = clamp_paragraph_byte_to_char_boundary(document, paragraph, offset.byte); + DocumentOffset { paragraph, byte } +} + +#[hotpath::measure] +fn extend_invalidation(target: &mut Option>, range: Range) { + if range.is_empty() { + return; + } + match target { + Some(existing) => { + existing.start = existing.start.min(range.start); + existing.end = existing.end.max(range.end); + }, + None => *target = Some(range), + } +} diff --git a/crates/gpui-flowtext/src/rich_text/editor/recovery.rs b/crates/gpui-flowtext/src/rich_text/editor/recovery.rs index e131564f..8721f733 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/recovery.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/recovery.rs @@ -148,6 +148,7 @@ impl RichTextEditor { let Some(path) = self.recovery_path.clone() else { return; }; + let source_path = self.document_path.clone(); if !self.has_unsaved_changes() { return; } @@ -159,9 +160,10 @@ impl RichTextEditor { return; } + let delay = self.recovery_write_delay(); self.recovery_write_in_progress = true; cx.spawn(async move |editor, cx| { - Timer::after(Duration::from_millis(750)).await; + Timer::after(delay).await; let snapshot_timing = Instant::now(); let decision = editor .update(cx, |editor, cx| { @@ -174,9 +176,18 @@ impl RichTextEditor { editor.recovery_write_in_progress = false; editor.schedule_recovery_write(cx); RecoveryWriteDecision::Rescheduled + } else if editor.last_text_input_at.is_some_and(|last_input| last_input.elapsed() < RECOVERY_TYPING_IDLE_WINDOW) { + editor.recovery_write_in_progress = false; + editor.schedule_recovery_write(cx); + RecoveryWriteDecision::Rescheduled } else if !editor.has_unsaved_changes() || editor.last_recovery_generation == editor.edit_generation { editor.recovery_write_in_progress = false; RecoveryWriteDecision::Idle + } else if let Some(hook) = editor.native_recovery_hook.clone() { + RecoveryWriteDecision::WriteRuntime { + generation: editor.edit_generation, + hook, + } } else { RecoveryWriteDecision::Write { generation: editor.edit_generation, @@ -186,19 +197,29 @@ impl RichTextEditor { }) .ok(); log_timing("recovery snapshot", snapshot_timing, ""); - let Some(RecoveryWriteDecision::Write { generation, document }) = decision else { - return; + let (generation, write_result, detail) = match decision { + Some(RecoveryWriteDecision::Write { generation, document }) => { + let write_timing = Instant::now(); + let paragraph_count = document.paragraphs.len(); + let result = cx + .background_executor() + .spawn(async move { + let document = detach_document_for_background_write(&document); + write_recovery_snapshot(&path, source_path.as_deref(), &document) + }) + .await; + log_timing_lazy("recovery write", write_timing, || format!("paragraphs={paragraph_count}")); + (generation, result, format!("paragraphs={paragraph_count}")) + }, + Some(RecoveryWriteDecision::WriteRuntime { generation, hook }) => { + let write_timing = Instant::now(); + let result = hook(path).await; + log_timing("runtime recovery write", write_timing, ""); + (generation, result, "runtime".to_string()) + }, + _ => return, }; - let write_timing = Instant::now(); - let paragraph_count = document.paragraphs.len(); - let write_result = cx - .background_executor() - .spawn(async move { - let document = detach_document_for_background_write(&document); - write_document(path, &document) - }) - .await; - log_timing_lazy("recovery write", write_timing, || format!("paragraphs={paragraph_count}")); + let _ = detail; match write_result { Ok(()) => { let _ = editor.update(cx, |editor, _| { @@ -224,4 +245,12 @@ impl RichTextEditor { .detach(); } + fn recovery_write_delay(&self) -> Duration { + self + .last_text_input_at + .and_then(|last_input| RECOVERY_TYPING_IDLE_WINDOW.checked_sub(last_input.elapsed())) + .filter(|delay| *delay > RECOVERY_WRITE_DEBOUNCE) + .unwrap_or(RECOVERY_WRITE_DEBOUNCE) + } + } diff --git a/crates/gpui-flowtext/src/rich_text/editor/render_blocks.rs b/crates/gpui-flowtext/src/rich_text/editor/render_blocks.rs index 9faa7b9e..b99d49c6 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/render_blocks.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/render_blocks.rs @@ -1,6 +1,6 @@ #[hotpath::measure] fn render_image_block( - document: &Document, + document: &DocumentProjection, image: &ImageBlock, block_ix: usize, row_size: Size, @@ -13,6 +13,9 @@ fn render_image_block( .child("Missing image") .into_any_element(); }; + if asset.is_loading_placeholder() { + return render_loading_image_placeholder(document, image, asset, row_size, selected, editor, block_ix); + } let Some(format) = ImageFormat::from_mime_type(asset.mime_type.as_ref()) else { return reserved_object_frame(document, row_size, selected) .child("Unsupported image") @@ -37,6 +40,34 @@ fn render_image_block( .into_any_element() } +#[hotpath::measure] +fn render_loading_image_placeholder( + document: &DocumentProjection, + image: &ImageBlock, + asset: &AssetRecord, + row_size: Size, + selected: bool, + editor: Entity, + block_ix: usize, +) -> gpui::AnyElement { + image_object_frame(document, image, asset, row_size, selected) + .child( + div() + .size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_1() + .text_sm() + .text_color(rgb(0x6b7280)) + .child(div().text_xs().text_color(rgb(0x9ca3af)).child("...")) + .child("Loading image"), + ) + .when(selected, |this| this.children(image_resize_handles(editor, block_ix))) + .into_any_element() +} + #[hotpath::measure] fn image_resize_handles(editor: Entity, block_ix: usize) -> Vec { [ @@ -100,7 +131,7 @@ fn image_resize_handle(editor: Entity, block_ix: usize, handle: #[hotpath::measure] fn render_equation_block( - document: &Document, + document: &DocumentProjection, equation: &EquationBlock, block_ix: usize, row_size: Size, @@ -254,4 +285,3 @@ type EquationRenderCache = FxHashMap>, Stri static EQUATION_SVG_CACHE: OnceLock> = OnceLock::new(); static EQUATION_PNG_CACHE: OnceLock> = OnceLock::new(); - diff --git a/crates/gpui-flowtext/src/rich_text/editor/search_highlights.rs b/crates/gpui-flowtext/src/rich_text/editor/search_highlights.rs index fd26b9b3..69f2d4d6 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/search_highlights.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/search_highlights.rs @@ -29,10 +29,11 @@ impl RichTextEditor { return false; }; - self.selection = EditorSelection { - anchor: range.start, - head: range.end, - }; + let before_selection = self.selection.clone(); + self.selection = EditorSelection::range(range.start, range.end); + if self.selection != before_selection { + self.emit_selection_changed(cx); + } self.apply_document_edit(cx, |editor, cx| { editor.insert_text(replacement, cx); }); @@ -82,10 +83,7 @@ impl RichTextEditor { } for range in cross_paragraph_ranges.into_iter().rev() { - editor.selection = EditorSelection { - anchor: range.start, - head: range.end, - }; + editor.selection = EditorSelection::range(range.start, range.end); editor.insert_text(replacement, cx); final_caret = Some(editor.selection.head); } @@ -105,7 +103,7 @@ impl RichTextEditor { replace_paragraph_blocks(&mut editor.document, start, replacements.len(), &replacements); } if let Some(caret) = final_caret { - editor.selection = EditorSelection { anchor: caret, head: caret }; + editor.selection = EditorSelection::collapsed(caret); } editor.after_text_mutation(cx); }); @@ -130,7 +128,7 @@ impl RichTextEditor { } fn replace_paragraph_matches( - document: &mut Document, + document: &mut DocumentProjection, paragraph_ix: usize, matches: &[Range], replacement: &str, diff --git a/crates/gpui-flowtext/src/rich_text/editor/send_export.rs b/crates/gpui-flowtext/src/rich_text/editor/send_export.rs index 9d4c2cb6..ed1a1923 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/send_export.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/send_export.rs @@ -6,15 +6,24 @@ pub trait DocumentExportAdapter: Send + Sync + 'static { .map(Path::to_path_buf) } - fn write_document_export(&self, output_path: &Path, document: &Document, format: DocumentExportFormat) -> io::Result<()>; + fn write_document_export(&self, output_path: &Path, document: &DocumentProjection, format: DocumentExportFormat) -> io::Result<()>; +} + +pub trait DocumentRecoveryAdapter: Send + Sync + 'static { + fn write_recovery_snapshot(&self, recovery_path: &Path, source_path: Option<&Path>, document: &DocumentProjection) -> io::Result<()>; } static DOCUMENT_EXPORT_ADAPTER: OnceLock> = OnceLock::new(); +static DOCUMENT_RECOVERY_ADAPTER: OnceLock> = OnceLock::new(); pub fn set_document_export_adapter(adapter: Arc) -> Result<(), Arc> { DOCUMENT_EXPORT_ADAPTER.set(adapter) } +pub fn set_document_recovery_adapter(adapter: Arc) -> Result<(), Arc> { + DOCUMENT_RECOVERY_ADAPTER.set(adapter) +} + #[hotpath::measure_all] impl RichTextEditor { pub fn send_document(&mut self, format: DocumentExportFormat, cx: &mut Context) -> Task> { @@ -29,6 +38,36 @@ impl RichTextEditor { }; let generation = self.edit_generation; let document = self.document.clone(); + if let Some(export_hook) = self.native_export_hook.clone() { + let pending_runtime_edits = self.take_pending_semantic_edits(); + let pending_runtime_edits_for_retry = pending_runtime_edits.clone(); + let selection_after = pending_runtime_edits + .iter() + .rev() + .find_map(|edit| edit.selection_after.clone()); + let assets = document.assets.assets.values().cloned().collect(); + return cx.spawn(async move |editor, cx| { + let result = export_hook(output_path.clone(), format, pending_runtime_edits, assets).await; + match result { + Ok(document) => { + let _ = editor.update(cx, |editor, cx| { + editor.replace_document_projection(document, cx); + editor.complete_runtime_edit(selection_after, cx); + editor.last_send_document_generation = Some(generation); + cx.notify(); + }); + Ok(output_path) + }, + Err(error) => { + let _ = editor.update(cx, |editor, cx| { + editor.prepend_pending_semantic_edits(pending_runtime_edits_for_retry); + cx.notify(); + }); + Err(error) + }, + } + }); + } cx.spawn(async move |editor, cx| { let result = cx .background_executor() @@ -59,6 +98,36 @@ impl RichTextEditor { }; let generation = self.edit_generation; let document = self.document.clone(); + if let Some(export_hook) = self.native_export_hook.clone() { + let pending_runtime_edits = self.take_pending_semantic_edits(); + let pending_runtime_edits_for_retry = pending_runtime_edits.clone(); + let selection_after = pending_runtime_edits + .iter() + .rev() + .find_map(|edit| edit.selection_after.clone()); + let assets = document.assets.assets.values().cloned().collect(); + return cx.spawn(async move |editor, cx| { + let result = export_hook(output_path.clone(), format, pending_runtime_edits, assets).await; + match result { + Ok(document) => { + let _ = editor.update(cx, |editor, cx| { + editor.replace_document_projection(document, cx); + editor.complete_runtime_edit(selection_after, cx); + editor.last_format_export_generation = Some(generation); + cx.notify(); + }); + Ok(output_path) + }, + Err(error) => { + let _ = editor.update(cx, |editor, cx| { + editor.prepend_pending_semantic_edits(pending_runtime_edits_for_retry); + cx.notify(); + }); + Err(error) + }, + } + }); + } cx.spawn(async move |editor, cx| { let result = cx .background_executor() @@ -195,15 +264,83 @@ impl DocumentExportFormat { } #[hotpath::measure] -fn write_document_export(output_path: &Path, document: &Document, format: DocumentExportFormat) -> io::Result<()> { +fn write_document_export(output_path: &Path, document: &DocumentProjection, format: DocumentExportFormat) -> io::Result<()> { if let Some(adapter) = DOCUMENT_EXPORT_ADAPTER.get() { return adapter.write_document_export(output_path, document, format); } - match format { - DocumentExportFormat::Native | DocumentExportFormat::NativeWithExtension(_) => write_document(output_path, document), - DocumentExportFormat::Docx | DocumentExportFormat::Pdf => Err(io::Error::new( - io::ErrorKind::Unsupported, - "DOCX and PDF export are host-application adapters; gpui-flowtext only writes its native binary format directly", - )), + let _ = (output_path, document, format); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "document export requires a host-application adapter", + )) +} + +#[hotpath::measure] +fn write_native_document(output_path: &Path, document: &DocumentProjection) -> io::Result<()> { + if let Some(adapter) = DOCUMENT_EXPORT_ADAPTER.get() { + return adapter.write_document_export(output_path, document, DocumentExportFormat::Native); + } + let _ = (output_path, document); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "native save requires a host-application adapter", + )) +} + +#[hotpath::measure] +fn write_recovery_snapshot(recovery_path: &Path, source_path: Option<&Path>, document: &DocumentProjection) -> io::Result<()> { + if let Some(adapter) = DOCUMENT_RECOVERY_ADAPTER.get() { + return adapter.write_recovery_snapshot(recovery_path, source_path, document); + } + let _ = (recovery_path, source_path, document); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "recovery snapshots require a host-application adapter", + )) +} + +#[cfg(test)] +mod send_export_tests { + use super::*; + + #[test] + fn native_extension_without_adapter_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("document.db8"); + + let error = write_document_export(&path, &blank_document(), DocumentExportFormat::NativeWithExtension("db8")).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert!(!path.exists()); + } + + #[test] + fn native_save_without_adapter_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("document.db8"); + + let error = write_native_document(&path, &blank_document()).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert!(!path.exists()); + } + + #[test] + fn recovery_for_db8_source_without_adapter_is_unsupported() { + let error = write_recovery_snapshot( + Path::new("document.db8.recovery"), + Some(Path::new("document.db8")), + &blank_document(), + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + #[test] + fn recovery_for_db8_path_without_source_without_adapter_is_unsupported() { + let error = write_recovery_snapshot(Path::new("collaboration.db8"), None, &blank_document()).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Unsupported); } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/serialization.rs b/crates/gpui-flowtext/src/rich_text/editor/serialization.rs index 4049c090..3e2ac647 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/serialization.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/serialization.rs @@ -1,21 +1,3 @@ -#[hotpath::measure] -fn canonical_insert_text_operations(paragraph_id: ParagraphId, start_byte: usize, paragraph: &InputParagraph) -> Vec { - let mut byte = start_byte; - let mut operations = Vec::with_capacity(paragraph.runs.len()); - for run in ¶graph.runs { - if !run.text.is_empty() { - operations.push(CanonicalOperation::InsertText { - paragraph: paragraph_id, - byte, - text: run.text.clone(), - styles: run.styles, - }); - } - byte += run.text.len(); - } - operations -} - #[hotpath::measure] pub(super) fn input_paragraph_text(paragraph: &InputParagraph) -> String { paragraph.runs.iter().map(|run| run.text.as_str()).collect() @@ -51,7 +33,7 @@ fn collect_block_assets(block: &Block, assets: &AssetStore, output: &mut Vec InputBlock { +pub fn input_block_from_block(block: &Block) -> InputBlock { match block { Block::Paragraph(paragraph) => InputBlock::Paragraph(input_paragraph_from_paragraph(paragraph)), Block::Image(image) => InputBlock::Image(InputImageBlock { @@ -78,7 +60,7 @@ fn input_block_from_block(block: &Block) -> InputBlock { } #[hotpath::measure] -fn block_from_input_block(block: &InputBlock) -> Block { +pub fn block_from_input_block(block: &InputBlock) -> Block { match block { InputBlock::Paragraph(paragraph) => Block::Paragraph(paragraph_from_input_paragraph(paragraph)), InputBlock::Image(image) => Block::Image(ImageBlock { @@ -106,6 +88,62 @@ fn block_from_input_block(block: &InputBlock) -> Block { } } +#[hotpath::measure] +#[must_use] +pub fn document_from_input_blocks(theme: DocumentTheme, input_blocks: Vec) -> DocumentProjection { + let mut text = String::new(); + let mut paragraphs = Vec::new(); + let mut blocks = Vec::with_capacity(input_blocks.len()); + + for input_block in input_blocks { + match input_block { + InputBlock::Paragraph(paragraph) => { + if !paragraphs.is_empty() { + text.push('\n'); + } + let start = text.len(); + text.push_str(&input_paragraph_text(¶graph)); + let mut paragraph = paragraph_from_input_paragraph(¶graph); + paragraph.byte_range = start..text.len(); + paragraphs.push(paragraph.clone()); + blocks.push(Block::Paragraph(paragraph)); + }, + InputBlock::Image(image) => blocks.push(block_from_input_block(&InputBlock::Image(image))), + InputBlock::Equation(equation) => blocks.push(block_from_input_block(&InputBlock::Equation(equation))), + InputBlock::Table(table) => blocks.push(block_from_input_block(&InputBlock::Table(table))), + } + } + + if paragraphs.is_empty() { + let paragraph = Paragraph { + style: ParagraphStyle::Normal, + byte_range: 0..0, + runs: Vec::new(), + version: 0, + }; + paragraphs.push(paragraph.clone()); + blocks.push(Block::Paragraph(paragraph)); + } + + let offset_index = ParagraphOffsetIndex::new(¶graphs); + let paragraph_count = paragraphs.len(); + let block_count = blocks.len(); + let mut document = DocumentProjection { + frontier: Vec::new(), + text: Rope::from(text), + paragraphs: Arc::new(paragraphs), + blocks: Arc::new(blocks), + assets: AssetStore::default(), + ids: document_ids_for_shape(paragraph_count, block_count), + sections: Arc::new(Vec::new()), + offset_index, + theme, + }; + reconcile_document_ids(&mut document); + rebuild_document_sections(&mut document); + document +} + #[hotpath::measure] fn input_paragraph_from_paragraph(paragraph: &Paragraph) -> InputParagraph { InputParagraph { @@ -122,7 +160,7 @@ fn input_paragraph_from_paragraph(paragraph: &Paragraph) -> InputParagraph { } #[hotpath::measure] -fn input_paragraph_from_document_range(document: &Document, paragraph_ix: usize, range: Range) -> InputParagraph { +fn input_paragraph_from_document_range(document: &DocumentProjection, paragraph_ix: usize, range: Range) -> InputParagraph { let paragraph = &document.paragraphs[paragraph_ix]; let paragraph_range = paragraph_byte_range(document, paragraph_ix); let start = range.start.min(paragraph_text_len(paragraph)); @@ -234,24 +272,7 @@ fn input_table_from_table(table: &TableBlock) -> InputTableBlock { rows: table .rows .iter() - .map(|row| InputTableRow { - cells: row - .cells - .iter() - .map(|cell| InputTableCell { - blocks: cell - .blocks - .iter() - .map(|block| match block { - TableCellBlock::Paragraph(paragraph) => InputTableCellBlock::Paragraph(input_paragraph_from_table_cell_paragraph(paragraph)), - TableCellBlock::Table(table) => InputTableCellBlock::Table(input_table_from_table(table)), - }) - .collect(), - row_span: cell.row_span, - col_span: cell.col_span, - }) - .collect(), - }) + .map(input_table_row_from_table_row) .collect(), column_widths: table .column_widths @@ -268,6 +289,38 @@ fn input_table_from_table(table: &TableBlock) -> InputTableBlock { } } +#[hotpath::measure] +fn input_table_row_from_table_row(row: &TableRow) -> InputTableRow { + InputTableRow { + cells: row.cells.iter().map(input_table_cell_from_table_cell).collect(), + } +} + +#[hotpath::measure] +fn input_table_cell_from_table_cell(cell: &TableCell) -> InputTableCell { + InputTableCell { + blocks: cell + .blocks + .iter() + .map(|block| match block { + TableCellBlock::Paragraph(paragraph) => InputTableCellBlock::Paragraph(input_paragraph_from_table_cell_paragraph(paragraph)), + TableCellBlock::Table(table) => InputTableCellBlock::Table(input_table_from_table(table)), + }) + .collect(), + row_span: cell.row_span, + col_span: cell.col_span, + } +} + +#[hotpath::measure] +fn input_table_column_width_from_table_column_width(width: &TableColumnWidth) -> InputTableColumnWidth { + match *width { + TableColumnWidth::Auto => InputTableColumnWidth::Auto, + TableColumnWidth::FixedPx(px) => InputTableColumnWidth::FixedPx(px), + TableColumnWidth::Fraction(fraction) => InputTableColumnWidth::Fraction(fraction), + } +} + #[hotpath::measure] fn table_from_input_table(table: &InputTableBlock) -> TableBlock { TableBlock { diff --git a/crates/gpui-flowtext/src/rich_text/editor/style_mutation.rs b/crates/gpui-flowtext/src/rich_text/editor/style_mutation.rs index 47ea3274..b55675f5 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/style_mutation.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/style_mutation.rs @@ -50,7 +50,12 @@ impl RichTextEditor { return; } if self.selection.is_caret() { - let paragraph_style = self.document.paragraphs[self.selection.head.paragraph].style; + let paragraph_style = self + .document + .paragraphs + .get(self.selection.head.paragraph) + .map(|paragraph| paragraph.style) + .unwrap_or(ParagraphStyle::Normal); let direct = explicit_direct.unwrap_or(matches!(paragraph_style, ParagraphStyle::Custom(3) | ParagraphStyle::Custom(4))); let mut styles = self.styles_at_caret(); if direct { @@ -201,8 +206,10 @@ impl RichTextEditor { return styles; } let caret = self.selection.head; - let paragraph = &self.document.paragraphs[caret.paragraph]; - let (run_ix, _) = run_containing(paragraph, caret.byte); + let Some(paragraph) = self.document.paragraphs.get(caret.paragraph) else { + return RunStyles::default(); + }; + let (run_ix, _) = run_containing(paragraph, caret.byte.min(paragraph_text_len(paragraph))); paragraph .runs .get(run_ix) diff --git a/crates/gpui-flowtext/src/rich_text/editor/style_state.rs b/crates/gpui-flowtext/src/rich_text/editor/style_state.rs index 07a2bd5e..675ec02d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/style_state.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/style_state.rs @@ -107,6 +107,19 @@ impl RichTextEditor { self.edit_generation } + pub fn mark_saved_generation(&mut self, generation: u64, cx: &mut Context) { + self.saved_generation = self.saved_generation.max(generation); + self.refresh_save_status(); + cx.notify(); + } + + pub fn set_save_failed(&mut self, generation: u64, message: String, cx: &mut Context) { + if generation >= self.saved_generation { + self.save_status = SaveStatus::SaveFailed(message); + } + cx.notify(); + } + pub fn update_document_theme(&mut self, update: impl FnOnce(&mut DocumentTheme), cx: &mut Context) { update(&mut self.document.theme); rebuild_document_sections(&mut self.document); @@ -266,12 +279,52 @@ impl RichTextEditor { let recovery_path = self.recovery_path.clone(); self.save_status = SaveStatus::Saving; cx.notify(); + if let Some(save_hook) = self.native_save_hook.clone() { + let pending_runtime_edits = self.take_pending_semantic_edits(); + let pending_runtime_edits_for_retry = pending_runtime_edits.clone(); + let selection_after = pending_runtime_edits + .iter() + .rev() + .find_map(|edit| edit.selection_after.clone()); + let assets = document.assets.assets.values().cloned().collect(); + return cx.spawn(async move |editor, cx| { + let write_result = save_hook(path, pending_runtime_edits, assets).await; + if write_result.is_ok() + && let Some(recovery_path) = recovery_path + { + let _ = fs::remove_file(recovery_path); + } + match write_result { + Ok(document) => { + let _ = editor.update(cx, |editor, cx| { + editor.replace_document_projection(document, cx); + editor.complete_runtime_edit(selection_after, cx); + editor.saved_generation = editor.saved_generation.max(generation); + editor.refresh_save_status(); + cx.notify(); + }); + Ok(()) + }, + Err(error) => { + let message = error.to_string(); + let _ = editor.update(cx, |editor, cx| { + editor.prepend_pending_semantic_edits(pending_runtime_edits_for_retry); + if generation >= editor.saved_generation { + editor.save_status = SaveStatus::SaveFailed(message); + } + cx.notify(); + }); + Err(error) + }, + } + }); + } cx.spawn(async move |editor, cx| { let write_result = cx .background_executor() .spawn(async move { let document = detach_document_for_background_write(&document); - let result = write_document(&path, &document); + let result = write_native_document(&path, &document); if result.is_ok() && let Some(recovery_path) = recovery_path { diff --git a/crates/gpui-flowtext/src/rich_text/editor/table_equation_editing.rs b/crates/gpui-flowtext/src/rich_text/editor/table_equation_editing.rs index a5fe2d24..6e404027 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/table_equation_editing.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/table_equation_editing.rs @@ -1,5 +1,15 @@ #[hotpath::measure_all] impl RichTextEditor { + fn insert_text_into_selected_object_text(&mut self, text: &str, cx: &mut Context) -> bool { + if self.insert_text_into_selected_table_cell(text, cx) { + return true; + } + if self.insert_text_into_selected_equation(text, cx) { + return true; + } + false + } + fn insert_text_into_selected_table_cell(&mut self, text: &str, cx: &mut Context) -> bool { let Some(BlockSelection::TableCell { block_ix, row_ix, cell_ix }) = self.selected_block else { return false; @@ -31,6 +41,28 @@ impl RichTextEditor { let Some(BlockSelection::TableCell { block_ix, row_ix, cell_ix }) = self.selected_block else { return false; }; + let paragraph_ix = self.table_cell_block_ix; + let caret = self.table_cell_caret; + let mut new_paragraph_ix = None; + self.edit_table_cell(block_ix, row_ix, cell_ix, cx, |cell| { + new_paragraph_ix = split_table_cell_paragraph_at(cell, paragraph_ix, caret); + }); + if let Some(paragraph_ix) = new_paragraph_ix { + self.table_cell_block_ix = paragraph_ix; + self.table_cell_caret = 0; + cx.notify(); + } + true + } + + fn edit_table_cell( + &mut self, + block_ix: usize, + row_ix: usize, + cell_ix: usize, + cx: &mut Context, + update: impl FnOnce(&mut TableCell), + ) -> bool { let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { return false; }; @@ -42,39 +74,44 @@ impl RichTextEditor { else { return false; }; - let Some(new_paragraph_ix) = split_table_cell_paragraph_at(cell, self.table_cell_block_ix, self.table_cell_caret) else { - return true; - }; + update(cell); if updated == table { - return true; + return false; } updated.version = updated.version.wrapping_add(1); - let before = Block::Table(table); - let after = Block::Table(updated); - if let Some(block) = Arc::make_mut(&mut self.document.blocks).get_mut(block_ix) { - *block = after.clone(); - } - let before_generation = self.edit_generation; - let after_generation = self.next_edit_generation; - self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { - before_selection: self.selection.clone(), - before_generation, - after_selection: self.selection.clone(), - after_generation, - operations: vec![EditOperation::ReplaceBlock { block_ix, before, after }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(block_ix), - }], - }); - self.redo_stack.clear(); - self.table_cell_block_ix = new_paragraph_ix; - self.table_cell_caret = 0; - self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + let semantic_commands = self.replace_table_cell_semantic_commands(block_ix, row_ix, cell_ix, &updated); + self.finish_selected_table_edit(block_ix, table, updated, semantic_commands, cx); true } + fn replace_table_cell_semantic_commands( + &self, + block_ix: usize, + row_ix: usize, + cell_ix: usize, + table: &TableBlock, + ) -> Vec { + let Some(table_id) = self.semantic_block_id(block_ix) else { + return self.missing_table_identity_semantic_commands(block_ix, "cell replace"); + }; + let Some(cell) = table + .rows + .get(row_ix) + .and_then(|row| row.cells.get(cell_ix)) + else { + eprintln!( + "skipping table-cell semantic command because block {block_ix} cell {row_ix}:{cell_ix} is out of range" + ); + return Vec::new(); + }; + vec![SemanticEditCommand::ReplaceTableCell { + table: table_id, + row_ix, + cell_ix, + cell: input_table_cell_from_table_cell(cell), + }] + } + fn insert_text_into_selected_equation(&mut self, text: &str, cx: &mut Context) -> bool { let Some(BlockSelection::Equation(block_ix)) = self.selected_block else { return false; @@ -87,24 +124,8 @@ impl RichTextEditor { .as_ref() .map(|range| range.start) .unwrap_or(self.equation_source_caret); - self.edit_selected_equation(block_ix, cx, |equation| { - let mut source = equation.source.to_string(); - let insert_at = insert_at.min(source.len()); - if !source.is_char_boundary(insert_at) { - return; - } - if let Some(range) = selection_range.clone() - && range.start <= range.end - && range.end <= source.len() - && source.is_char_boundary(range.start) - && source.is_char_boundary(range.end) - { - source.replace_range(range, ""); - } - source.insert_str(insert_at, text); - equation.source = source.into(); - equation.version = equation.version.wrapping_add(1); - }); + let range = selection_range.unwrap_or(insert_at..insert_at); + self.edit_selected_equation_source_range(block_ix, range, text, cx); self.equation_source_caret = insert_at.saturating_add(text.len()); self.equation_source_anchor = self.equation_source_caret; true @@ -118,14 +139,7 @@ impl RichTextEditor { if caret == 0 { let mut merged_caret = None; let current_paragraph_ix = self.table_cell_block_ix; - self.edit_selected_table(cx, |table| { - let Some(cell) = table - .rows - .get_mut(row_ix) - .and_then(|row| row.cells.get_mut(cell_ix)) - else { - return; - }; + self.edit_table_cell(block_ix, row_ix, cell_ix, cx, |cell| { merged_caret = merge_table_cell_paragraph_with_previous(cell, current_paragraph_ix); }); if let Some((paragraph_ix, byte)) = merged_caret { @@ -188,14 +202,7 @@ impl RichTextEditor { } else { let mut merged_caret = None; let current_paragraph_ix = self.table_cell_block_ix; - self.edit_selected_table(cx, |table| { - let Some(cell) = table - .rows - .get_mut(row_ix) - .and_then(|row| row.cells.get_mut(cell_ix)) - else { - return; - }; + self.edit_table_cell(block_ix, row_ix, cell_ix, cx, |cell| { merged_caret = merge_table_cell_paragraph_with_next(cell, current_paragraph_ix); }); if let Some((paragraph_ix, byte)) = merged_caret { @@ -224,42 +231,48 @@ impl RichTextEditor { let selection_range = self.equation_source_selection_range(); let caret = self.equation_source_caret; let mut next_caret = caret; - self.edit_selected_equation(block_ix, cx, |equation| { - let mut source = equation.source.to_string(); - if let Some(range) = selection_range.clone() - && range.start <= range.end - && range.end <= source.len() - && source.is_char_boundary(range.start) - && source.is_char_boundary(range.end) - { - source.replace_range(range.clone(), ""); - next_caret = range.start; - equation.source = source.into(); - equation.version = equation.version.wrapping_add(1); - return; - } + let Some(source) = self.selected_equation_source() else { + return true; + }; + if let Some(range) = selection_range + && range.start <= range.end + && range.end <= source.len() + && source.is_char_boundary(range.start) + && source.is_char_boundary(range.end) + { + next_caret = range.start; + self.edit_selected_equation_source_range(block_ix, range, "", cx); + } else { let caret = caret.min(source.len()); if caret > 0 && source.is_char_boundary(caret) && let Some((byte, _)) = source[..caret].char_indices().next_back() { - source.replace_range(byte..caret, ""); next_caret = byte; - equation.source = source.into(); - equation.version = equation.version.wrapping_add(1); + self.edit_selected_equation_source_range(block_ix, byte..caret, "", cx); } - }); + } self.equation_source_caret = next_caret; self.equation_source_anchor = next_caret; true } - fn edit_selected_equation(&mut self, block_ix: usize, cx: &mut Context, update: impl FnOnce(&mut EquationBlock)) { + fn edit_selected_equation_source_range(&mut self, block_ix: usize, range: Range, text: &str, cx: &mut Context) { let Some(Block::Equation(equation)) = self.document.blocks.get(block_ix).cloned() else { return; }; let mut updated = equation.clone(); - update(&mut updated); + let mut source = updated.source.to_string(); + if range.start > range.end + || range.end > source.len() + || !source.is_char_boundary(range.start) + || !source.is_char_boundary(range.end) + { + return; + } + source.replace_range(range.clone(), text); + updated.source = source.into(); + updated.version = updated.version.wrapping_add(1); if updated == equation { return; } @@ -271,19 +284,26 @@ impl RichTextEditor { let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + let semantic_commands = if let Some(equation_id) = self.semantic_block_id(block_ix) { + vec![SemanticEditCommand::ReplaceEquationSourceRange { + equation: equation_id, + range, + text: text.to_string(), + }] + } else { + eprintln!("skipping equation source semantic command because projection block {block_ix} has no durable id"); + Vec::new() + }; + self.record_local_history(EditRecord { before_selection: self.selection.clone(), before_generation, after_selection: self.selection.clone(), after_generation, operations: vec![EditOperation::ReplaceBlock { block_ix, before, after }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(block_ix), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); } pub(super) fn edit_table_cell_paragraph( @@ -294,52 +314,19 @@ impl RichTextEditor { cx: &mut Context, update: impl FnOnce(&mut TableCellParagraph), ) { - let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { - return; - }; - let mut updated = table.clone(); - let Some(cell) = updated - .rows - .get_mut(row_ix) - .and_then(|row| row.cells.get_mut(cell_ix)) - else { - return; - }; - let paragraph_ix = table_cell_paragraph_block_ix(cell, self.table_cell_block_ix).unwrap_or_else(|| { - cell - .blocks - .push(TableCellBlock::Paragraph(default_table_cell_paragraph())); - cell.blocks.len() - 1 - }); - let TableCellBlock::Paragraph(paragraph) = &mut cell.blocks[paragraph_ix] else { - return; - }; - update(paragraph); - if updated == table { - return; - } - updated.version = updated.version.wrapping_add(1); - let before = Block::Table(table); - let after = Block::Table(updated); - if let Some(block) = Arc::make_mut(&mut self.document.blocks).get_mut(block_ix) { - *block = after.clone(); - } - let before_generation = self.edit_generation; - let after_generation = self.next_edit_generation; - self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { - before_selection: self.selection.clone(), - before_generation, - after_selection: self.selection.clone(), - after_generation, - operations: vec![EditOperation::ReplaceBlock { block_ix, before, after }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(block_ix), - }], + let preferred_paragraph_ix = self.table_cell_block_ix; + self.edit_table_cell(block_ix, row_ix, cell_ix, cx, |cell| { + let paragraph_ix = table_cell_paragraph_block_ix(cell, preferred_paragraph_ix).unwrap_or_else(|| { + cell + .blocks + .push(TableCellBlock::Paragraph(default_table_cell_paragraph())); + cell.blocks.len() - 1 + }); + let TableCellBlock::Paragraph(paragraph) = &mut cell.blocks[paragraph_ix] else { + return; + }; + update(paragraph); }); - self.redo_stack.clear(); - self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/tables.rs b/crates/gpui-flowtext/src/rich_text/editor/tables.rs index 29fb890c..107b785f 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/tables.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/tables.rs @@ -37,21 +37,38 @@ impl RichTextEditor { Some(BlockSelection::TableCell { row_ix, .. }) => Some(row_ix), _ => None, }; - self.edit_selected_table(cx, |table| { - let columns = table - .rows - .iter() - .map(|row| row.cells.len()) - .max() - .unwrap_or(1) - .max(table.column_widths.len()) - .max(1); - let insert_ix = target_row - .map(|row| row + 1) - .unwrap_or(table.rows.len()) - .min(table.rows.len()); - table.rows.insert(insert_ix, default_table_row(columns)); - }); + let Some(block_ix) = self.selected_table_block_ix() else { + return; + }; + let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { + return; + }; + let mut updated = table.clone(); + let columns = updated + .rows + .iter() + .map(|row| row.cells.len()) + .max() + .unwrap_or(1) + .max(updated.column_widths.len()) + .max(1); + let insert_ix = target_row + .map(|row| row + 1) + .unwrap_or(updated.rows.len()) + .min(updated.rows.len()); + let row = default_table_row(columns); + updated.rows.insert(insert_ix, row.clone()); + updated.version = updated.version.wrapping_add(1); + let semantic_commands = if let Some(table_id) = self.semantic_block_id(block_ix) { + vec![SemanticEditCommand::InsertTableRow { + table: table_id, + row_ix: insert_ix, + row: input_table_row_from_table_row(&row), + }] + } else { + self.missing_table_identity_semantic_commands(block_ix, "row insert") + }; + self.finish_selected_table_edit(block_ix, table, updated, semantic_commands, cx); } pub fn delete_last_row_from_selected_table(&mut self, cx: &mut Context) { @@ -59,14 +76,27 @@ impl RichTextEditor { Some(BlockSelection::TableCell { row_ix, .. }) => Some(row_ix), _ => None, }; - self.edit_selected_table(cx, |table| { - if table.rows.len() > 1 { - let row_ix = target_row - .unwrap_or(table.rows.len() - 1) - .min(table.rows.len() - 1); - table.rows.remove(row_ix); - } - }); + let Some(block_ix) = self.selected_table_block_ix() else { + return; + }; + let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { + return; + }; + if table.rows.len() <= 1 { + return; + } + let row_ix = target_row + .unwrap_or(table.rows.len() - 1) + .min(table.rows.len() - 1); + let mut updated = table.clone(); + updated.rows.remove(row_ix); + updated.version = updated.version.wrapping_add(1); + let semantic_commands = if let Some(table_id) = self.semantic_block_id(block_ix) { + vec![SemanticEditCommand::DeleteTableRow { table: table_id, row_ix }] + } else { + self.missing_table_identity_semantic_commands(block_ix, "row delete") + }; + self.finish_selected_table_edit(block_ix, table, updated, semantic_commands, cx); } pub fn insert_column_after_selected_table(&mut self, cx: &mut Context) { @@ -74,19 +104,38 @@ impl RichTextEditor { Some(BlockSelection::TableCell { cell_ix, .. }) => Some(cell_ix), _ => None, }; - self.edit_selected_table(cx, |table| { - let insert_ix = target_column - .map(|column| column + 1) - .unwrap_or(table.column_widths.len()) - .min(table.column_widths.len()); - table - .column_widths - .insert(insert_ix, TableColumnWidth::Fraction(1)); - for row in &mut table.rows { - let cell_ix = insert_ix.min(row.cells.len()); - row.cells.insert(cell_ix, default_table_cell()); - } - }); + let Some(block_ix) = self.selected_table_block_ix() else { + return; + }; + let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { + return; + }; + let mut updated = table.clone(); + let insert_ix = target_column + .map(|column| column + 1) + .unwrap_or(updated.column_widths.len()) + .min(updated.column_widths.len()); + let width = TableColumnWidth::Fraction(1); + updated.column_widths.insert(insert_ix, width.clone()); + let mut inserted_cells = Vec::with_capacity(updated.rows.len()); + for row in &mut updated.rows { + let cell = default_table_cell(); + inserted_cells.push(input_table_cell_from_table_cell(&cell)); + let cell_ix = insert_ix.min(row.cells.len()); + row.cells.insert(cell_ix, cell); + } + updated.version = updated.version.wrapping_add(1); + let semantic_commands = if let Some(table_id) = self.semantic_block_id(block_ix) { + vec![SemanticEditCommand::InsertTableColumn { + table: table_id, + column_ix: insert_ix, + width: input_table_column_width_from_table_column_width(&width), + cells: inserted_cells, + }] + } else { + self.missing_table_identity_semantic_commands(block_ix, "column insert") + }; + self.finish_selected_table_edit(block_ix, table, updated, semantic_commands, cx); } pub fn delete_last_column_from_selected_table(&mut self, cx: &mut Context) { @@ -94,29 +143,46 @@ impl RichTextEditor { Some(BlockSelection::TableCell { cell_ix, .. }) => Some(cell_ix), _ => None, }; - self.edit_selected_table(cx, |table| { - if table.column_widths.len() > 1 { - let column_ix = target_column - .unwrap_or(table.column_widths.len() - 1) - .min(table.column_widths.len() - 1); - table.column_widths.remove(column_ix); - for row in &mut table.rows { - if row.cells.len() > 1 { - let cell_ix = column_ix.min(row.cells.len() - 1); - row.cells.remove(cell_ix); - } + let Some(block_ix) = self.selected_table_block_ix() else { + return; + }; + let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { + return; + }; + let mut updated = table.clone(); + let mut structured_column_ix = None; + if updated.column_widths.len() > 1 { + let column_ix = target_column + .unwrap_or(updated.column_widths.len() - 1) + .min(updated.column_widths.len() - 1); + updated.column_widths.remove(column_ix); + for row in &mut updated.rows { + if row.cells.len() > 1 { + let cell_ix = column_ix.min(row.cells.len() - 1); + row.cells.remove(cell_ix); } - } else { - for row in &mut table.rows { - if row.cells.len() > 1 { - let cell_ix = target_column - .unwrap_or(row.cells.len() - 1) - .min(row.cells.len() - 1); - row.cells.remove(cell_ix); - } + } + structured_column_ix = Some(column_ix); + } else { + for row in &mut updated.rows { + if row.cells.len() > 1 { + let cell_ix = target_column + .unwrap_or(row.cells.len() - 1) + .min(row.cells.len() - 1); + row.cells.remove(cell_ix); } } - }); + } + if updated == table { + return; + } + updated.version = updated.version.wrapping_add(1); + let semantic_commands = if let (Some(table_id), Some(column_ix)) = (self.semantic_block_id(block_ix), structured_column_ix) { + vec![SemanticEditCommand::DeleteTableColumn { table: table_id, column_ix }] + } else { + self.missing_table_identity_semantic_commands(block_ix, "column delete") + }; + self.finish_selected_table_edit(block_ix, table, updated, semantic_commands, cx); } pub fn widen_selected_table_column(&mut self, cx: &mut Context) { @@ -132,52 +198,83 @@ impl RichTextEditor { Some(BlockSelection::TableCell { cell_ix, .. }) => cell_ix, _ => return, }; - self.edit_selected_table(cx, |table| { - if target_column >= table.column_widths.len() { - return; - } - let current = match table.column_widths[target_column] { - TableColumnWidth::FixedPx(width) => width as i32, - TableColumnWidth::Fraction(_) | TableColumnWidth::Auto => 120, - }; - table.column_widths[target_column] = TableColumnWidth::FixedPx((current + delta_px).clamp(32, 1600) as u32); - }); - } - - fn edit_selected_table(&mut self, cx: &mut Context, update: impl FnOnce(&mut TableBlock)) { let Some(block_ix) = self.selected_table_block_ix() else { return; }; let Some(Block::Table(table)) = self.document.blocks.get(block_ix).cloned() else { return; }; + if target_column >= table.column_widths.len() { + return; + } let mut updated = table.clone(); - update(&mut updated); + let current = match updated.column_widths[target_column] { + TableColumnWidth::FixedPx(width) => width as i32, + TableColumnWidth::Fraction(_) | TableColumnWidth::Auto => 120, + }; + updated.column_widths[target_column] = TableColumnWidth::FixedPx((current + delta_px).clamp(32, 1600) as u32); if updated == table { return; } updated.version = updated.version.wrapping_add(1); - let before = Block::Table(table); - let after = Block::Table(updated.clone()); + let semantic_commands = if let Some(table_id) = self.semantic_block_id(block_ix) { + vec![SemanticEditCommand::SetTableColumnWidth { + table: table_id, + column_ix: target_column, + width: input_table_column_width_from_table_column_width(&updated.column_widths[target_column]), + }] + } else { + self.missing_table_identity_semantic_commands(block_ix, "column width") + }; + self.finish_selected_table_edit(block_ix, table, updated, semantic_commands, cx); + } + + fn finish_selected_table_edit( + &mut self, + block_ix: usize, + before_table: TableBlock, + updated: TableBlock, + semantic_commands: Vec, + cx: &mut Context, + ) { + self.finish_table_edit( + block_ix, + Block::Table(before_table), + Block::Table(updated), + semantic_commands, + cx, + ); + } + + fn finish_table_edit( + &mut self, + block_ix: usize, + before: Block, + after: Block, + semantic_commands: Vec, + cx: &mut Context, + ) { if let Some(block) = Arc::make_mut(&mut self.document.blocks).get_mut(block_ix) { *block = after.clone(); } let before_generation = self.edit_generation; let after_generation = self.next_edit_generation; self.next_edit_generation = self.next_edit_generation.wrapping_add(1); - self.undo_stack.push(EditRecord { + self.record_local_history(EditRecord { before_selection: self.selection.clone(), before_generation, after_selection: self.selection.clone(), after_generation, operations: vec![EditOperation::ReplaceBlock { block_ix, before, after }], - canonical_operations: vec![CanonicalOperation::ReplaceBlock { - block: self.identity_map.block_id(block_ix), - }], + semantic_commands: semantic_commands.clone(), }); - self.redo_stack.clear(); self.invalidate_document_layout_caches(); - self.mark_document_changed(after_generation, cx); + self.mark_document_changed_with_ops(after_generation, true, Some(&semantic_commands), cx); + } + + fn missing_table_identity_semantic_commands(&self, block_ix: usize, operation: &'static str) -> Vec { + eprintln!("skipping table {operation} semantic command because projection block {block_ix} has no durable id"); + Vec::new() } fn selected_table_block_ix(&self) -> Option { diff --git a/crates/gpui-flowtext/src/rich_text/editor/traits.rs b/crates/gpui-flowtext/src/rich_text/editor/traits.rs index d093ea7b..80390957 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/traits.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/traits.rs @@ -136,8 +136,8 @@ impl Render for RichTextEditor { .clone() .map(|preview| render_drop_preview(preview, editor.invisibility_mode, cx).into_any_element()) .unwrap_or_else(|| EmptyVirtualItemElement.into_any_element()), - RenderVirtualItem::Document(VirtualItem::HiddenBlock { .. }) => EmptyVirtualItemElement.into_any_element(), - RenderVirtualItem::Document(VirtualItem::ParagraphChunk { + RenderVirtualItem::DocumentProjection(VirtualItem::HiddenBlock { .. }) => EmptyVirtualItemElement.into_any_element(), + RenderVirtualItem::DocumentProjection(VirtualItem::ParagraphChunk { paragraph_ix, chunk_ix, .. @@ -150,7 +150,7 @@ impl Render for RichTextEditor { layout: WordElementLayout::default(), } .into_any_element(), - RenderVirtualItem::Document(VirtualItem::ParagraphRemainder { paragraph_ix, .. }) => { + RenderVirtualItem::DocumentProjection(VirtualItem::ParagraphRemainder { paragraph_ix, .. }) => { let width = editor.current_layout_width(); let chunk_ix = editor.materialize_paragraph_remainder_for_render(paragraph_ix, width, window, cx); if let Some(chunk_ix) = chunk_ix { @@ -167,7 +167,7 @@ impl Render for RichTextEditor { EmptyVirtualItemElement.into_any_element() } }, - RenderVirtualItem::Document(VirtualItem::StructuralBlock { block_ix }) => { + RenderVirtualItem::DocumentProjection(VirtualItem::StructuralBlock { block_ix }) => { let editor_entity = cx.entity(); let selection = match editor.document.blocks.get(block_ix) { Some(Block::Image(_)) => Some(BlockSelection::Image(block_ix)), diff --git a/crates/gpui-flowtext/src/rich_text/editor/virtual_helpers.rs b/crates/gpui-flowtext/src/rich_text/editor/virtual_helpers.rs index bdedb19e..ce5fee1b 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/virtual_helpers.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/virtual_helpers.rs @@ -102,7 +102,7 @@ fn expand_paragraph_range(range: Range, paragraph_count: usize, padding: } #[hotpath::measure] -fn byte_at_ratio_in_paragraph(document: &Document, paragraph_ix: usize, start_byte: usize, end_byte: usize, ratio: f32) -> usize { +fn byte_at_ratio_in_paragraph(document: &DocumentProjection, paragraph_ix: usize, start_byte: usize, end_byte: usize, ratio: f32) -> usize { let Some(paragraph) = document.paragraphs.get(paragraph_ix) else { return 0; }; @@ -117,8 +117,9 @@ fn byte_at_ratio_in_paragraph(document: &Document, paragraph_ix: usize, start_by } #[hotpath::measure] -fn detach_document_for_background_write(document: &Document) -> Document { - Document { +fn detach_document_for_background_write(document: &DocumentProjection) -> DocumentProjection { + DocumentProjection { + frontier: document.frontier.clone(), text: document.text.clone(), paragraphs: Arc::new(document.paragraphs.as_ref().clone()), blocks: Arc::new(document.blocks.as_ref().clone()), diff --git a/crates/gpui-flowtext/src/rich_text/element.rs b/crates/gpui-flowtext/src/rich_text/element.rs index c719585b..8a8a1eda 100644 --- a/crates/gpui-flowtext/src/rich_text/element.rs +++ b/crates/gpui-flowtext/src/rich_text/element.rs @@ -1,21 +1,21 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - App, AvailableSpace, Background, Bounds, Element, ElementId, Entity, GlobalElementId, InspectorElementId, IntoElement, LayoutId, Pixels, - Style, Window, fill, px, relative, + App, AvailableSpace, Background, Bounds, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, Pixels, Style, Window, fill, px, relative, }; use super::*; pub struct RichTextDocumentElement { - pub(super) document: Document, + pub(super) document: DocumentProjection, pub(super) layout: WordElementLayout, pub(super) invisibility_mode: bool, } #[hotpath::measure_all] impl RichTextDocumentElement { - pub fn new(document: Document) -> Self { + pub fn new(document: DocumentProjection) -> Self { Self { document, layout: WordElementLayout::default(), @@ -161,7 +161,7 @@ impl Element for RichTextDocumentElement { cx: &mut App, ) { if let Some((layout, bounds)) = self.layout.positioned() { - paint_layout(layout.as_ref(), bounds, None, None, false, px(1.0), &[], &[], None, window, cx); + paint_layout(layout.as_ref(), bounds, None, None, false, px(1.0), None, &[], &[], None, window, cx); } } } @@ -239,7 +239,7 @@ impl Element for VirtualParagraphChunkElement { window: &mut Window, cx: &mut App, ) { - let (selection, drag_selection, caret_offset, caret_width, external_carets, search_highlights, active_search_highlight) = { + let (selection, drag_selection, caret_offset, caret_width, caret_color_rgb, external_carets, search_highlights, active_search_highlight) = { let editor = self.editor.read(cx); let drag_selection = editor.drag_source_selection(); let external_carets = editor.external_carets_for_paragraph(self.paragraph_ix); @@ -253,12 +253,15 @@ impl Element for VirtualParagraphChunkElement { && editor.focus_handle.is_focused(window)) .then_some(editor.selection.head), editor.caret_paint_width(), + editor.local_caret_color_rgb(), external_carets, editor.search_highlights.clone(), editor.active_search_highlight, ) }; if let Some((layout, bounds)) = self.layout.positioned() { + let focus_handle = self.editor.read(cx).focus_handle.clone(); + window.handle_input(&focus_handle, ElementInputHandler::new(bounds, self.editor.clone()), cx); if self.chunk_ix == 0 { let collapse_state = self .editor @@ -298,6 +301,7 @@ impl Element for VirtualParagraphChunkElement { drag_selection.as_ref(), show_caret, caret_width, + caret_color_rgb, &external_carets, &search_highlights, active_search_highlight, @@ -489,7 +493,7 @@ fn packed_element_pair(first: usize, second: usize) -> u64 { #[hotpath::measure] pub(super) fn request_word_layout( - document: Document, + document: DocumentProjection, layout_cell: WordElementLayout, invisibility_mode: bool, window: &mut Window, diff --git a/crates/gpui-flowtext/src/rich_text/invisibility.rs b/crates/gpui-flowtext/src/rich_text/invisibility.rs index f612f7e5..3712a437 100644 --- a/crates/gpui-flowtext/src/rich_text/invisibility.rs +++ b/crates/gpui-flowtext/src/rich_text/invisibility.rs @@ -37,7 +37,7 @@ pub(super) struct VisibilityIndex { #[hotpath::measure_all] impl VisibilityIndex { - pub(super) fn build(document: &Document, invisibility_mode: bool) -> Self { + pub(super) fn build(document: &DocumentProjection, invisibility_mode: bool) -> Self { let mut visible_blocks = Vec::with_capacity(document.blocks.len()); for block in document.blocks.iter() { @@ -60,7 +60,7 @@ impl VisibilityIndex { } #[hotpath::measure] -pub(super) fn paragraph_is_visible(document: &Document, paragraph: &Paragraph) -> bool { +pub(super) fn paragraph_is_visible(document: &DocumentProjection, paragraph: &Paragraph) -> bool { paragraph_is_visible_for_theme(&document.theme, paragraph) } @@ -86,7 +86,7 @@ pub(super) fn paragraph_is_visible_for_theme(theme: &DocumentTheme, paragraph: & pub(super) const INVISIBILITY_PROJECTED_VERSION_OFFSET: u64 = 0x9E37_79B9_7F4A_7C15; #[hotpath::measure] -pub(super) fn invisibility_projected_document(document: &Document, paragraph_ix: usize) -> Option { +pub(super) fn invisibility_projected_document(document: &DocumentProjection, paragraph_ix: usize) -> Option { let paragraph = document.paragraphs.get(paragraph_ix)?; if !matches!(paragraph.style, ParagraphStyle::Normal) { return None; @@ -105,7 +105,8 @@ pub(super) fn invisibility_projected_document(document: &Document, paragraph_ix: .wrapping_add(INVISIBILITY_PROJECTED_VERSION_OFFSET), }; let paragraphs = Arc::new(vec![paragraph.clone()]); - let mut projected = Document { + let mut projected = DocumentProjection { + frontier: document.frontier.clone(), text: Rope::from(text), blocks: Arc::new(vec![Block::Paragraph(paragraph)]), paragraphs: paragraphs.clone(), @@ -120,7 +121,7 @@ pub(super) fn invisibility_projected_document(document: &Document, paragraph_ix: } #[hotpath::measure] -pub(super) fn run_is_visible(document: &Document, styles: RunStyles) -> bool { +pub(super) fn run_is_visible(document: &DocumentProjection, styles: RunStyles) -> bool { run_is_visible_for_theme(&document.theme, styles) } @@ -158,7 +159,7 @@ pub(super) fn decode_remainder_item_ix(encoded: u32) -> Option { } #[hotpath::measure] -pub(super) fn projected_visible_paragraph_text_and_runs(document: &Document, paragraph_ix: usize) -> Option<(String, Vec)> { +pub(super) fn projected_visible_paragraph_text_and_runs(document: &DocumentProjection, paragraph_ix: usize) -> Option<(String, Vec)> { let paragraph = document.paragraphs.get(paragraph_ix)?; let paragraph_start = paragraph_byte_range(document, paragraph_ix).start; let paragraph_len = paragraph_text_len(paragraph); diff --git a/crates/gpui-flowtext/src/rich_text/layout/block_layout.rs b/crates/gpui-flowtext/src/rich_text/layout/block_layout.rs index 9d45f715..3266073d 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/block_layout.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/block_layout.rs @@ -1,6 +1,6 @@ #[hotpath::measure] pub(super) fn build_layout( - document: &Document, + document: &DocumentProjection, width: Pixels, previous_layout: Option<&LayoutState>, window: &mut Window, @@ -53,7 +53,7 @@ pub(super) fn build_layout( #[hotpath::measure] pub(super) fn build_layout_with_visibility( - document: &Document, + document: &DocumentProjection, width: Pixels, previous_layout: Option<&LayoutState>, invisibility_mode: bool, @@ -133,7 +133,7 @@ pub(super) fn build_layout_with_visibility( #[hotpath::measure] pub(super) fn build_single_paragraph_layout_with_visibility( - document: &Document, + document: &DocumentProjection, paragraph_ix: usize, width: Pixels, previous_layout: Option<&LayoutState>, @@ -207,7 +207,7 @@ pub(super) fn build_single_paragraph_layout_with_visibility( #[allow(dead_code, reason = "Block layout helper is retained for incremental object-layout work.")] #[hotpath::measure] pub(super) fn build_structural_block_layout( - document: &Document, + document: &DocumentProjection, width: Pixels, previous_layout: Option<&LayoutState>, window: &mut Window, @@ -266,14 +266,22 @@ pub(super) fn build_structural_block_layout( } #[hotpath::measure] -fn structural_block_bounds(document: &Document, width: Pixels, y: Pixels, height: Pixels) -> Bounds { +fn structural_block_bounds(document: &DocumentProjection, width: Pixels, y: Pixels, height: Pixels) -> Bounds { let left = document.theme.pageless_inset_x; let block_width = (width - document.theme.pageless_inset_x * 2.0).max(px(1.0)); Bounds::new(point(left, y), size(block_width, height.max(px(1.0)))) } #[hotpath::measure] -fn image_placeholder_height(document: &Document, image: &ImageBlock, width: Pixels) -> Pixels { +fn image_placeholder_height(document: &DocumentProjection, image: &ImageBlock, width: Pixels) -> Pixels { + if document + .assets + .assets + .get(&image.asset_id) + .is_some_and(AssetRecord::is_loading_placeholder) + { + return px(IMAGE_LOADING_PLACEHOLDER_HEIGHT_PX); + } let available_width = (width - document.theme.pageless_inset_x * 2.0).max(px(1.0)); let intrinsic = image_intrinsic_size(document, image); match image.sizing { @@ -286,15 +294,12 @@ fn image_placeholder_height(document: &Document, image: &ImageBlock, width: Pixe } } -#[cfg(test)] #[hotpath::measure] -pub(super) fn image_layout_height_for_test(document: &Document, image: &ImageBlock, width: Pixels) -> Pixels { - image_placeholder_height(document, image, width) -} - -#[hotpath::measure] -fn image_intrinsic_size(document: &Document, image: &ImageBlock) -> Option<(Pixels, Pixels)> { +fn image_intrinsic_size(document: &DocumentProjection, image: &ImageBlock) -> Option<(Pixels, Pixels)> { let asset = document.assets.assets.get(&image.asset_id)?; + if asset.is_loading_placeholder() { + return None; + } let size = imagesize::blob_size(asset.bytes.as_ref()).ok()?; if size.width == 0 || size.height == 0 { return None; @@ -315,7 +320,7 @@ fn image_height_for_width(intrinsic: Option<(Pixels, Pixels)>, width: Pixels) -> } #[hotpath::measure] -fn equation_placeholder_height(document: &Document, equation: &EquationBlock) -> Pixels { +fn equation_placeholder_height(document: &DocumentProjection, equation: &EquationBlock) -> Pixels { match equation.display { EquationDisplay::Display => (document.theme.body_font_size * document.theme.zoom_factor.max(0.01) * 3.7).max(px(72.0)), EquationDisplay::InlineLikeParagraph => (document.theme.body_font_size * document.theme.zoom_factor.max(0.01) * 2.75).max(px(56.0)), @@ -324,7 +329,7 @@ fn equation_placeholder_height(document: &Document, equation: &EquationBlock) -> #[hotpath::measure] pub(super) fn layout_structural_block_at( - document: &Document, + document: &DocumentProjection, block_ix: usize, width: Pixels, y: Pixels, diff --git a/crates/gpui-flowtext/src/rich_text/layout/decorations.rs b/crates/gpui-flowtext/src/rich_text/layout/decorations.rs index 8f2f5383..f3cd7f00 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/decorations.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/decorations.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -pub(super) fn rects_for_line(document: &Document, line: &LaidOutLine) -> Vec { +pub(super) fn rects_for_line(document: &DocumentProjection, line: &LaidOutLine) -> Vec { let mut backgrounds = Vec::new(); let mut borders = Vec::new(); let text_top = line.baseline_y() - line.ascent; @@ -148,7 +148,7 @@ pub(super) fn push_box_rules(rects: &mut Vec, bounds: Bounds, t } #[hotpath::measure] -pub(super) fn underlines_for_line(document: &Document, line: &LaidOutLine, cx: &mut App) -> Vec { +pub(super) fn underlines_for_line(document: &DocumentProjection, line: &LaidOutLine, cx: &mut App) -> Vec { let mut underlines = Vec::with_capacity(line.segments.len().saturating_mul(2)); let baseline = line.baseline_y(); for segment in &line.segments { @@ -188,7 +188,7 @@ pub(super) fn underlines_for_line(document: &Document, line: &LaidOutLine, cx: & } #[hotpath::measure] -pub(super) fn strikethroughs_for_line(document: &Document, line: &LaidOutLine) -> Vec { +pub(super) fn strikethroughs_for_line(document: &DocumentProjection, line: &LaidOutLine) -> Vec { let baseline = line.baseline_y(); let mut decorations = Vec::with_capacity(line.segments.len()); for segment in &line.segments { @@ -271,7 +271,7 @@ fn same_color(a: Hsla, b: Hsla) -> bool { } #[hotpath::measure] -pub(super) fn single_underline_metrics_for_segment(segment: &LaidOutSegment, document: &Document, cx: &mut App) -> (Pixels, Pixels) { +pub(super) fn single_underline_metrics_for_segment(segment: &LaidOutSegment, document: &DocumentProjection, cx: &mut App) -> (Pixels, Pixels) { // GPUI exposes glyph bounds in font coordinates. For Calibri, the // underscore bbox is below the baseline. The origin is the lower // edge of the glyph box on this metric path; Word positions an @@ -297,7 +297,7 @@ pub(super) fn single_underline_metrics_for_segment(segment: &LaidOutSegment, doc } #[hotpath::measure] -pub(super) fn double_underline_metrics_for_segment(document: &Document) -> (Pixels, Pixels) { +pub(super) fn double_underline_metrics_for_segment(document: &DocumentProjection) -> (Pixels, Pixels) { (document.theme.double_underline_top_from_baseline, document.theme.underline_rule_thickness) } diff --git a/crates/gpui-flowtext/src/rich_text/layout/estimates.rs b/crates/gpui-flowtext/src/rich_text/layout/estimates.rs index 6adfd675..ea39e011 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/estimates.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/estimates.rs @@ -1,11 +1,11 @@ #[hotpath::measure] -pub(super) fn estimate_paragraph_item_height(document: &Document, paragraph_ix: usize, width: Pixels) -> Pixels { +pub(super) fn estimate_paragraph_item_height(document: &DocumentProjection, paragraph_ix: usize, width: Pixels) -> Pixels { estimate_paragraph_item_height_with_visibility(document, paragraph_ix, width, false) } #[hotpath::measure] pub(super) fn estimate_paragraph_item_height_with_visibility( - document: &Document, + document: &DocumentProjection, paragraph_ix: usize, width: Pixels, invisibility_mode: bool, @@ -50,7 +50,7 @@ pub(super) fn estimate_paragraph_item_height_with_visibility( } #[hotpath::measure] -pub(super) fn estimate_paragraph_prep_item_height(document: &Document, prep: &ParagraphPrep, width: Pixels) -> Pixels { +pub(super) fn estimate_paragraph_prep_item_height(document: &DocumentProjection, prep: &ParagraphPrep, width: Pixels) -> Pixels { if !prep.visible { return px(0.0); } @@ -80,7 +80,7 @@ pub(super) fn estimate_paragraph_prep_item_height(document: &Document, prep: &Pa } #[hotpath::measure] -pub(super) fn estimate_structural_block_item_height(document: &Document, block_ix: usize, width: Pixels) -> Pixels { +pub(super) fn estimate_structural_block_item_height(document: &DocumentProjection, block_ix: usize, width: Pixels) -> Pixels { let Some(block) = document.blocks.get(block_ix) else { return px(1.0); }; @@ -102,7 +102,7 @@ pub(super) fn estimate_structural_block_item_height(document: &Document, block_i } #[hotpath::measure] -fn table_placeholder_height(document: &Document, table: &TableBlock, width: Pixels) -> Pixels { +fn table_placeholder_height(document: &DocumentProjection, table: &TableBlock, width: Pixels) -> Pixels { let line_height = (document.theme.body_font_size * document.theme.zoom_factor.max(0.01) * document.theme.line_spacing).max(px(16.0)); let column_count = table .column_widths @@ -152,7 +152,7 @@ fn table_placeholder_height(document: &Document, table: &TableBlock, width: Pixe } #[hotpath::measure] -fn layout_table_block_without_text(document: &Document, table: &TableBlock, width: Pixels, y: Pixels) -> LaidOutTable { +fn layout_table_block_without_text(document: &DocumentProjection, table: &TableBlock, width: Pixels, y: Pixels) -> LaidOutTable { let table_left = document.theme.pageless_inset_x; let table_width = (width - document.theme.pageless_inset_x * 2.0).max(px(1.0)); let column_count = table diff --git a/crates/gpui-flowtext/src/rich_text/layout/format.rs b/crates/gpui-flowtext/src/rich_text/layout/format.rs index 9f09fb48..09983c14 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/format.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/format.rs @@ -1,5 +1,5 @@ #[hotpath::measure] -pub(super) fn paragraph_format(document: &Document, style: ParagraphStyle) -> EffectiveParagraphFormat { +pub(super) fn paragraph_format(document: &DocumentProjection, style: ParagraphStyle) -> EffectiveParagraphFormat { let theme = &document.theme; let zoom = theme.zoom_factor.max(0.01); let normal = EffectiveParagraphFormat { @@ -46,7 +46,7 @@ pub(super) fn paragraph_format(document: &Document, style: ParagraphStyle) -> Ef } #[hotpath::measure] -pub(super) fn run_format(document: &Document, paragraph: &EffectiveParagraphFormat, styles: RunStyles) -> EffectiveRunFormat { +pub(super) fn run_format(document: &DocumentProjection, paragraph: &EffectiveParagraphFormat, styles: RunStyles) -> EffectiveRunFormat { let theme = &document.theme; let zoom = theme.zoom_factor.max(0.01); let mut format = EffectiveRunFormat { diff --git a/crates/gpui-flowtext/src/rich_text/layout/hit_test.rs b/crates/gpui-flowtext/src/rich_text/layout/hit_test.rs index c2412724..aef41abf 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/hit_test.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/hit_test.rs @@ -44,13 +44,14 @@ pub(super) fn first_line_with_bottom_at_or_after(lines: &[LaidOutLine], y: Pixel } #[hotpath::measure] -pub(super) fn caret_bounds(layout: &LayoutState, offset: DocumentOffset, origin: Point) -> Option> { +pub(super) fn caret_bounds(layout: &LayoutState, offset: DocumentOffset, gravity: VisualGravity, origin: Point) -> Option> { // Use locate_line so the caret is drawn on the same visual line that - // Up/Down/Home/End navigate from — in particular the wrap-seam bias - // (byte at end of line k == start of line k+1 → paint on k+1) must be - // identical in both paths, otherwise the caret appears at the wrong - // position after the cursor reaches a soft-wrap boundary. - let (p_ix, l_ix) = locate_line(layout, offset)?; + // Up/Down/Home/End navigate from — in particular the wrap-seam bias must be + // identical in both paths, otherwise the caret appears at the wrong position + // after the cursor reaches a soft-wrap boundary. The seam choice is now + // driven by §16 visual gravity (Upstream → end of upper line, otherwise → + // start of lower line). + let (p_ix, l_ix) = locate_line(layout, offset, gravity)?; let line = layout.paragraphs[p_ix].lines.get(l_ix)?; let x = x_for_byte(line, offset.byte); Some(Bounds::new(origin + line.origin + point(x, px(0.0)), size(px(1.0), line.line_height))) @@ -104,11 +105,14 @@ fn line_ix_for_byte(paragraph: &LaidOutParagraph, byte: usize) -> Option // Locate the `LaidOutLine` containing the given offset. Returns // `(paragraph_layout_index, line_index)`. When the byte sits exactly on a -// soft-wrap seam (== end_byte of line k and start_byte of line k+1), we bias -// to the next line — matching Word's "caret-at-start-of-next-line" -// convention. This is exactly the disambiguation called out in the plan. +// soft-wrap seam (== end_byte of line k and start_byte of line k+1), the §16 +// visual `gravity` disambiguates which visual line the caret belongs to: +// `Upstream` keeps it on the upper line (end of line k); `Downstream`/`Neutral` +// bias to the lower line (start of line k+1), matching the historical +// "caret-at-start-of-next-line" convention. This is exactly the disambiguation +// called out in the plan. #[hotpath::measure] -pub(super) fn locate_line(layout: &LayoutState, off: DocumentOffset) -> Option<(usize, usize)> { +pub(super) fn locate_line(layout: &LayoutState, off: DocumentOffset, gravity: VisualGravity) -> Option<(usize, usize)> { let p_ix = paragraph_layout_index_for_offset(layout, off)?; let para = &layout.paragraphs[p_ix]; let mut low = 0; @@ -125,8 +129,13 @@ pub(super) fn locate_line(layout: &LayoutState, off: DocumentOffset) -> Option<( && off.byte >= line.start_byte && off.byte <= line.end_byte { - // Bias to next line at exact wrap seam. - if off.byte == line.end_byte && low + 1 < para.lines.len() && para.lines[low + 1].start_byte == off.byte { + // At an exact wrap seam, choose the visual line from gravity. Upstream stays + // on the line that ends here (`low`); otherwise bias to the next line. + if off.byte == line.end_byte + && low + 1 < para.lines.len() + && para.lines[low + 1].start_byte == off.byte + && !matches!(gravity, VisualGravity::Upstream) + { return Some((p_ix, low + 1)); } return Some((p_ix, low)); diff --git a/crates/gpui-flowtext/src/rich_text/layout/paragraph_layout.rs b/crates/gpui-flowtext/src/rich_text/layout/paragraph_layout.rs index b1137d5a..750e4654 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/paragraph_layout.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/paragraph_layout.rs @@ -1,6 +1,6 @@ #[hotpath::measure] pub(super) fn layout_paragraph_at( - document: &Document, + document: &DocumentProjection, paragraph_ix: usize, width: Pixels, previous_bottom: Pixels, @@ -90,7 +90,7 @@ pub(super) struct ParagraphChunkBuildResult { #[allow(clippy::too_many_arguments, reason = "Paragraph layout needs several independent shaping and theme inputs.")] #[hotpath::measure] pub(super) fn build_paragraph_chunk_layout_with_visibility( - document: &Document, + document: &DocumentProjection, paragraph_ix: usize, width: Pixels, start_byte: usize, @@ -170,7 +170,7 @@ pub(super) fn build_paragraph_chunk_layout_with_visibility( #[allow(clippy::too_many_arguments, reason = "Paragraph layout needs several independent shaping and theme inputs.")] #[hotpath::measure] fn layout_paragraph_chunk_at( - document: &Document, + document: &DocumentProjection, layout_paragraph_ix: usize, display_paragraph_ix: usize, width: Pixels, @@ -204,7 +204,7 @@ fn layout_paragraph_chunk_at( #[allow(clippy::too_many_arguments, reason = "Paragraph layout needs several independent shaping and theme inputs.")] #[hotpath::measure] fn layout_prepared_paragraph_chunk_at( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, display_paragraph_ix: usize, width: Pixels, diff --git a/crates/gpui-flowtext/src/rich_text/layout/prep.rs b/crates/gpui-flowtext/src/rich_text/layout/prep.rs index 90f270c7..e058cdbd 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/prep.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/prep.rs @@ -105,7 +105,7 @@ pub(super) fn build_paragraph_prep_batch(request: ParagraphPrepBatchRequest) -> #[hotpath::measure] pub(super) fn paragraph_prep_batch_request( - document: &Document, + document: &DocumentProjection, edit_generation: u64, invisibility_mode: bool, paragraphs: Vec, @@ -218,7 +218,7 @@ fn build_paragraph_prep_from_parts( #[hotpath::measure] pub(super) fn build_paragraph_prep( - document: &Document, + document: &DocumentProjection, paragraph_ix: usize, edit_generation: u64, invisibility_mode: bool, diff --git a/crates/gpui-flowtext/src/rich_text/layout/shaping.rs b/crates/gpui-flowtext/src/rich_text/layout/shaping.rs index 1c40bd83..25978b2b 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/shaping.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/shaping.rs @@ -1,6 +1,6 @@ #[hotpath::measure] pub(super) fn measure_line_width( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: &EffectiveParagraphFormat, paragraph_text: &str, @@ -70,7 +70,7 @@ pub(super) fn measure_line_width( #[hotpath::measure] pub(super) fn shape_line( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: EffectiveParagraphFormat, line_text: &str, @@ -253,7 +253,7 @@ pub(super) struct FormattedFragment { #[cfg(test)] #[hotpath::measure] pub(super) fn formatted_fragments_for_range( - document: &Document, + document: &DocumentProjection, p_format: &EffectiveParagraphFormat, paragraph: &Paragraph, range: &Range, @@ -275,7 +275,7 @@ pub(super) fn formatted_fragments_for_range( #[hotpath::measure] pub(super) fn formatted_fragments_for_range_into( - document: &Document, + document: &DocumentProjection, p_format: &EffectiveParagraphFormat, paragraph: &Paragraph, range: &Range, diff --git a/crates/gpui-flowtext/src/rich_text/layout/table_layout.rs b/crates/gpui-flowtext/src/rich_text/layout/table_layout.rs index e8a97129..10d62474 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/table_layout.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/table_layout.rs @@ -1,6 +1,6 @@ #[hotpath::measure] fn layout_table_block( - document: &Document, + document: &DocumentProjection, block_ix: usize, table: &TableBlock, width: Pixels, @@ -60,7 +60,7 @@ fn layout_table_block( } #[hotpath::measure] -fn table_row_height(document: &Document, row: &TableRow, column_widths: &[Pixels], window: &mut Window, cx: &mut App) -> Pixels { +fn table_row_height(document: &DocumentProjection, row: &TableRow, column_widths: &[Pixels], window: &mut Window, cx: &mut App) -> Pixels { let mut column_ix = 0; row .cells @@ -120,7 +120,7 @@ fn spanned_column_width(column_widths: &[Pixels], column_ix: usize, span: usize) } #[hotpath::measure] -fn table_cell_height(document: &Document, cell: &TableCell, width: Pixels, window: &mut Window, cx: &mut App) -> Pixels { +fn table_cell_height(document: &DocumentProjection, cell: &TableCell, width: Pixels, window: &mut Window, cx: &mut App) -> Pixels { let padding = table_cell_padding(); let content_width = (width - padding * 2.0).max(px(1.0)); let mut y = padding; @@ -144,7 +144,7 @@ fn table_cell_height(document: &Document, cell: &TableCell, width: Pixels, windo #[hotpath::measure] fn layout_table_cell_blocks( - document: &Document, + document: &DocumentProjection, cell: &TableCell, bounds: Bounds, window: &mut Window, @@ -173,7 +173,7 @@ fn layout_table_cell_blocks( #[hotpath::measure] fn layout_table_cell_paragraph( - document: &Document, + document: &DocumentProjection, cell_paragraph: &TableCellParagraph, index: usize, width: Pixels, diff --git a/crates/gpui-flowtext/src/rich_text/layout/types.rs b/crates/gpui-flowtext/src/rich_text/layout/types.rs index 7fd83bfa..ad395da8 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/types.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/types.rs @@ -142,7 +142,7 @@ pub(super) struct ParagraphHeightCacheEntry { } #[hotpath::measure] -pub(super) fn paragraph_cache_key(_document: &Document, paragraph: &Paragraph) -> ParagraphCacheKey { +pub(super) fn paragraph_cache_key(_document: &DocumentProjection, paragraph: &Paragraph) -> ParagraphCacheKey { paragraph_cache_key_for_paragraph(paragraph) } diff --git a/crates/gpui-flowtext/src/rich_text/layout/wrapping.rs b/crates/gpui-flowtext/src/rich_text/layout/wrapping.rs index e9188410..3927afaf 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/wrapping.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/wrapping.rs @@ -1,6 +1,6 @@ #[hotpath::measure] pub(super) fn wrap_lines( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: EffectiveParagraphFormat, text: &str, @@ -60,7 +60,7 @@ pub(super) fn wrap_lines( #[hotpath::measure] fn wrap_lines_limited( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: EffectiveParagraphFormat, text: &str, @@ -142,7 +142,7 @@ fn wrap_lines_limited( #[hotpath::measure] fn wrap_text_segment_limited( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: EffectiveParagraphFormat, text: &str, @@ -311,7 +311,7 @@ fn wrap_text_segment_limited( #[hotpath::measure] fn push_wrapped_soft_segment( lines: &mut Vec, - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: EffectiveParagraphFormat, text: &str, @@ -340,7 +340,7 @@ fn push_wrapped_soft_segment( #[hotpath::measure] fn wrap_text_segment( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: EffectiveParagraphFormat, text: &str, @@ -502,7 +502,7 @@ fn first_break_after(break_ends: &[usize], byte: usize) -> usize { #[allow(clippy::too_many_arguments, reason = "Wrapping needs independent font, width, style, and cache inputs.")] #[hotpath::measure] fn first_break_over_width( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: &EffectiveParagraphFormat, text: &str, @@ -596,7 +596,7 @@ pub(super) fn skip_leading_whitespace(text: &str, mut byte: usize) -> usize { #[hotpath::measure] pub(super) fn first_overflow_line_end( - document: &Document, + document: &DocumentProjection, paragraph: &Paragraph, p_format: &EffectiveParagraphFormat, text: &str, diff --git a/crates/gpui-flowtext/src/rich_text/mod.rs b/crates/gpui-flowtext/src/rich_text/mod.rs index 4c4f9272..a5640cde 100644 --- a/crates/gpui-flowtext/src/rich_text/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/mod.rs @@ -1,4 +1,4 @@ -// Submodules. Document model, edit helpers, persistence, demo builders, and +// Submodules. DocumentProjection model, edit helpers, persistence, demo builders, and // collaboration IDs live at the crate root; this module exposes the reusable // GPUI rich-text editor and rendering internals. mod benchmarks; diff --git a/crates/gpui-flowtext/src/rich_text/paint.rs b/crates/gpui-flowtext/src/rich_text/paint.rs index 3a295249..90fcb000 100644 --- a/crates/gpui-flowtext/src/rich_text/paint.rs +++ b/crates/gpui-flowtext/src/rich_text/paint.rs @@ -12,6 +12,7 @@ pub(super) fn paint_layout( drag_selection: Option<&EditorSelection>, show_caret: bool, caret_width: Pixels, + caret_color_rgb: Option, external_carets: &[ExternalCaret], search_highlights: &[Range], active_search_highlight: Option, @@ -99,17 +100,18 @@ pub(super) fn paint_layout( if let Some(selection) = selection && selection.is_caret() && show_caret - && let Some(mut caret) = caret_bounds(layout, selection.head, bounds.origin) + && let Some(mut caret) = caret_bounds(layout, selection.head, selection.head_gravity, bounds.origin) && caret.intersects(&content_mask) { caret.size.width = caret_width; - window.paint_quad(fill(snap_vertical_rule_to_device_pixels(caret, window), black())); + let caret_color = caret_color_rgb.map_or_else(|| Background::from(black()), |color_rgb| Background::from(rgb(color_rgb))); + window.paint_quad(fill(snap_vertical_rule_to_device_pixels(caret, window), caret_color)); } for external_caret in external_carets { - if let Some(mut caret) = caret_bounds(layout, external_caret.offset, bounds.origin) + if let Some(mut caret) = caret_bounds(layout, external_caret.offset, external_caret.visual_gravity, bounds.origin) && caret.intersects(&content_mask) { - caret.size.width = px(2.0); + caret.size.width = caret_width; window.paint_quad(fill( snap_vertical_rule_to_device_pixels(caret, window), Background::from(rgb(external_caret.color_rgb)), @@ -527,10 +529,7 @@ fn paint_search_highlights( if highlight.end.paragraph < visible_start || highlight.start.paragraph > visible_end { continue; } - let selection = EditorSelection { - anchor: highlight.start, - head: highlight.end, - }; + let selection = EditorSelection::range(highlight.start, highlight.end); paint_text_range_fill( layout, &selection, diff --git a/crates/gpui-flowtext/src/rich_text/selection.rs b/crates/gpui-flowtext/src/rich_text/selection.rs index 2d97db8b..9973cd93 100644 --- a/crates/gpui-flowtext/src/rich_text/selection.rs +++ b/crates/gpui-flowtext/src/rich_text/selection.rs @@ -8,7 +8,7 @@ pub(super) struct MouseSelectionOptions { #[hotpath::measure] pub(super) fn expand_mouse_selection( - document: &Document, + document: &DocumentProjection, anchor: DocumentOffset, head: DocumentOffset, granularity: SelectionGranularity, @@ -18,7 +18,7 @@ pub(super) fn expand_mouse_selection( return expand_drag_selection(document, anchor, head, granularity); } - let exact = EditorSelection { anchor, head }; + let exact = EditorSelection::range(anchor, head); if options.exact || !options.smart_word_selection || same_word_fragment(document, anchor, head) { return exact; } @@ -27,22 +27,16 @@ pub(super) fn expand_mouse_selection( } #[hotpath::measure] -fn smart_word_selection(document: &Document, anchor: DocumentOffset, head: DocumentOffset) -> EditorSelection { +fn smart_word_selection(document: &DocumentProjection, anchor: DocumentOffset, head: DocumentOffset) -> EditorSelection { if head < anchor { - EditorSelection { - anchor: smart_word_selection_end(document, anchor), - head: smart_word_selection_start(document, head), - } + EditorSelection::range(smart_word_selection_end(document, anchor), smart_word_selection_start(document, head)) } else { - EditorSelection { - anchor: smart_word_selection_start(document, anchor), - head: smart_word_selection_end(document, head), - } + EditorSelection::range(smart_word_selection_start(document, anchor), smart_word_selection_end(document, head)) } } #[hotpath::measure] -fn smart_word_selection_start(document: &Document, offset: DocumentOffset) -> DocumentOffset { +fn smart_word_selection_start(document: &DocumentProjection, offset: DocumentOffset) -> DocumentOffset { let Some(text) = paragraph_text_for_offset(document, offset) else { return DocumentOffset::default(); }; @@ -60,7 +54,7 @@ fn smart_word_selection_start(document: &Document, offset: DocumentOffset) -> Do } #[hotpath::measure] -fn smart_word_selection_end(document: &Document, offset: DocumentOffset) -> DocumentOffset { +fn smart_word_selection_end(document: &DocumentProjection, offset: DocumentOffset) -> DocumentOffset { let Some(text) = paragraph_text_for_offset(document, offset) else { return DocumentOffset::default(); }; @@ -78,7 +72,7 @@ fn smart_word_selection_end(document: &Document, offset: DocumentOffset) -> Docu } #[hotpath::measure] -fn paragraph_text_for_offset(document: &Document, offset: DocumentOffset) -> Option { +fn paragraph_text_for_offset(document: &DocumentProjection, offset: DocumentOffset) -> Option { if document.paragraphs.is_empty() { return None; } @@ -112,7 +106,7 @@ fn previous_word_end_at_or_before(text: &str, mut byte: usize) -> usize { } #[hotpath::measure] -pub(super) fn offset_is_in_same_word_as(document: &Document, anchor: DocumentOffset, offset: DocumentOffset) -> bool { +pub(super) fn offset_is_in_same_word_as(document: &DocumentProjection, anchor: DocumentOffset, offset: DocumentOffset) -> bool { if anchor.paragraph != offset.paragraph || document.paragraphs.is_empty() { return false; } @@ -126,7 +120,7 @@ pub(super) fn offset_is_in_same_word_as(document: &Document, anchor: DocumentOff } #[hotpath::measure] -fn same_word_fragment(document: &Document, anchor: DocumentOffset, head: DocumentOffset) -> bool { +fn same_word_fragment(document: &DocumentProjection, anchor: DocumentOffset, head: DocumentOffset) -> bool { if anchor.paragraph != head.paragraph || anchor == head { return false; } diff --git a/crates/gpui-flowtext/src/rich_text/tests/collab_capture.rs b/crates/gpui-flowtext/src/rich_text/tests/collab_capture.rs new file mode 100644 index 00000000..beda4c53 --- /dev/null +++ b/crates/gpui-flowtext/src/rich_text/tests/collab_capture.rs @@ -0,0 +1,801 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::AppContext as _; + +struct SelectionEventRecorder { + selections: Rc>>, + _subscription: gpui::Subscription, +} + +#[gpui::test] +fn collab_capture_fast_path_emits_single_grapheme_deltas(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + assert!(editor.insert_single_grapheme_fast_path("a", cx)); + assert!(editor.insert_single_grapheme_fast_path("b", cx)); + assert!(editor.insert_single_grapheme_fast_path("c", cx)); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 3); + for (edit, (expected_text, expected_byte)) in edits.iter().zip([("a", 0), ("b", 1), ("c", 2)]) { + let [SemanticEditCommand::InsertText { at, text, .. }] = edit.semantic_commands.as_slice() else { + panic!("expected one semantic InsertText command, got {:?}", edit.semantic_commands); + }; + assert_eq!(text, expected_text); + assert_eq!( + *at, + DocumentOffset { + paragraph: 0, + byte: expected_byte, + } + ); + } + + let edits_after_undo = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.undo(cx); + editor.take_pending_session_edits() + }) + }); + assert!(edits_after_undo.is_empty()); + + let paste_edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + let fragment = RichClipboardFragment { + format: RICH_TEXT_CLIPBOARD_FORMAT.to_string(), + paragraphs: vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("paste")], + }], + blocks: Vec::new(), + assets: Vec::new(), + }; + assert!(editor.insert_rich_fragment_paste_at_caret(&fragment, cx)); + editor.take_pending_session_edits() + }) + }); + assert_eq!(paste_edits.len(), 1); + assert!(matches!( + paste_edits[0].semantic_commands.as_slice(), + [SemanticEditCommand::ReplaceParagraphSpan { .. }] + )); + + let edits_after_paste_undo = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.undo(cx); + editor.take_pending_session_edits() + }) + }); + assert!(edits_after_paste_undo.is_empty()); +} + +#[gpui::test] +fn runtime_capture_fast_path_emits_single_grapheme_deltas(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_runtime_capture(true); + assert!(editor.insert_single_grapheme_fast_path("a", cx)); + assert!(editor.insert_single_grapheme_fast_path("b", cx)); + assert!(editor.insert_single_grapheme_fast_path("c", cx)); + editor.take_pending_runtime_edits() + }) + }); + + assert_eq!(edits.len(), 3); + for (edit, (expected_text, expected_byte)) in edits.iter().zip([("a", 0), ("b", 1), ("c", 2)]) { + let [SemanticEditCommand::InsertText { at, text, .. }] = edit.semantic_commands.as_slice() else { + panic!("expected one semantic InsertText command, got {:?}", edit.semantic_commands); + }; + assert_eq!(text, expected_text); + assert_eq!( + *at, + DocumentOffset { + paragraph: 0, + byte: expected_byte, + } + ); + } + + let edits_after_undo = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.undo(cx); + editor.take_pending_runtime_edits() + }) + }); + assert!(edits_after_undo.is_empty()); +} + +#[gpui::test] +fn stale_runtime_caret_style_lookup_is_safe(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, _| { + editor.selection = EditorSelection::collapsed(DocumentOffset { paragraph: 1, byte: 0 }); + assert_eq!(editor.styles_at_caret(), RunStyles::default()); + }); + }); +} + +#[gpui::test] +fn applying_collab_patches_does_not_arm_local_caret_scroll(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.pending_scroll_head_after_layout_for_test()); + let before_generation = editor.edit_generation(); + editor.apply_projection_patches( + &[ProjectionPatch::ParagraphText { + row: 0, + new: InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("remote")], + }, + delta_utf8: vec![ProjectionTextDelta::Insert("remote".len())], + }], + cx, + ); + assert!(!editor.pending_scroll_head_after_layout_for_test()); + assert!(editor.edit_generation() > before_generation); + }); + }); +} + +#[gpui::test] +fn applying_collab_asset_records_updates_asset_cache(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + let asset_id = AssetId(42); + let bytes = vec![1, 2, 3, 4]; + let record = AssetRecord { + id: asset_id, + mime_type: "image/png".into(), + original_name: Some("figure.png".into()), + content_hash: AssetRecord::stable_content_hash(&bytes), + bytes: std::sync::Arc::new(bytes), + }; + let before_generation = editor.edit_generation(); + editor.apply_synced_asset_records(&[(asset_id, record.clone())], cx); + + assert_eq!(editor.document().assets.assets.get(&asset_id), Some(&record)); + assert!(editor.edit_generation() > before_generation); + assert!(!editor.pending_scroll_head_after_layout_for_test()); + }); + }); +} + +#[gpui::test] +fn object_insertion_emits_insert_block_semantic_command(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.insert_default_table(2, 2, cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let [SemanticEditCommand::InsertBlock { block_ix, .. }] = edits[0].semantic_commands.as_slice() else { + panic!("expected one semantic InsertBlock command, got {:?}", edits[0].semantic_commands); + }; + assert_eq!(*block_ix, 0); +} + +#[gpui::test] +fn paragraph_block_insertion_emits_paragraph_span_command(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.insert_toolkit_paragraphs_as_blocks(vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("inserted")], + }], cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let [SemanticEditCommand::ReplaceParagraphSpan { before, after, .. }] = edits[0].semantic_commands.as_slice() else { + panic!( + "expected one semantic ReplaceParagraphSpan command, got {:?}", + edits[0].semantic_commands + ); + }; + assert_eq!(before.paragraphs.len(), 1); + assert_eq!(after.paragraphs.len(), 2); + assert_eq!(after.text, "\ninserted"); +} + +#[gpui::test] +fn mixed_block_insertion_emits_paragraph_span_and_insert_block_commands(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.insert_block_fragment_for_test( + RichClipboardFragment { + format: RICH_TEXT_CLIPBOARD_FORMAT.to_string(), + paragraphs: Vec::new(), + blocks: vec![ + InputBlock::Equation(InputEquationBlock { + source: "x+1".to_string(), + syntax: InputEquationSyntax::Latex, + display: InputEquationDisplay::Display, + }), + InputBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("after")], + }), + ], + assets: Vec::new(), + }, + cx, + ); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let commands = edits[0].semantic_commands.as_slice(); + assert_eq!(commands.len(), 2); + assert!(matches!(commands[0], SemanticEditCommand::ReplaceParagraphSpan { .. })); + assert!(matches!(commands[1], SemanticEditCommand::InsertBlock { block_ix: 1, .. })); +} + +#[gpui::test] +fn block_insertion_over_text_selection_emits_structured_commands(cx: &mut gpui::TestAppContext) { + let document = document_from_input( + DocumentTheme::default(), + vec![ + InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("alpha")], + }, + InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("omega")], + }, + ], + ); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.set_text_selection_for_test( + DocumentOffset { paragraph: 0, byte: 2 }, + DocumentOffset { paragraph: 1, byte: 2 }, + cx, + ); + editor.insert_default_table(1, 1, cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let commands = edits[0].semantic_commands.as_slice(); + assert_eq!(commands.len(), 2); + assert!(matches!(commands[0], SemanticEditCommand::ReplaceParagraphSpan { .. })); + assert!(matches!(commands[1], SemanticEditCommand::InsertBlock { .. })); +} + +#[gpui::test] +fn table_column_width_edit_emits_structured_semantic_command(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.insert_default_table(2, 2, cx); + editor.set_session_capture(true); + editor.select_table_cell_for_test(0, 0, 1, cx); + editor.widen_selected_table_column(cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let [SemanticEditCommand::SetTableColumnWidth { column_ix, width, .. }] = edits[0].semantic_commands.as_slice() else { + panic!( + "expected one semantic SetTableColumnWidth command, got {:?}", + edits[0].semantic_commands + ); + }; + assert_eq!(*column_ix, 1); + assert!(matches!(width, InputTableColumnWidth::FixedPx(144))); +} + +#[gpui::test] +fn table_structure_edits_emit_structured_semantic_commands(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.insert_default_table(2, 2, cx); + editor.set_session_capture(true); + editor.select_table_cell_for_test(0, 0, 0, cx); + + editor.insert_row_after_selected_table(cx); + let edits = editor.take_pending_session_edits(); + let [SemanticEditCommand::InsertTableRow { row_ix, row, .. }] = edits[0].semantic_commands.as_slice() else { + panic!("expected one semantic InsertTableRow command, got {:?}", edits[0].semantic_commands); + }; + assert_eq!(*row_ix, 1); + assert_eq!(row.cells.len(), 2); + + editor.insert_column_after_selected_table(cx); + let edits = editor.take_pending_session_edits(); + let [SemanticEditCommand::InsertTableColumn { + column_ix, + width, + cells, + .. + }] = edits[0].semantic_commands.as_slice() else { + panic!( + "expected one semantic InsertTableColumn command, got {:?}", + edits[0].semantic_commands + ); + }; + assert_eq!(*column_ix, 1); + assert!(matches!(width, InputTableColumnWidth::Fraction(1))); + assert_eq!(cells.len(), 3); + + editor.delete_last_row_from_selected_table(cx); + let edits = editor.take_pending_session_edits(); + let [SemanticEditCommand::DeleteTableRow { row_ix, .. }] = edits[0].semantic_commands.as_slice() else { + panic!("expected one semantic DeleteTableRow command, got {:?}", edits[0].semantic_commands); + }; + assert_eq!(*row_ix, 0); + + editor.delete_last_column_from_selected_table(cx); + let edits = editor.take_pending_session_edits(); + let [SemanticEditCommand::DeleteTableColumn { column_ix, .. }] = edits[0].semantic_commands.as_slice() else { + panic!( + "expected one semantic DeleteTableColumn command, got {:?}", + edits[0].semantic_commands + ); + }; + assert_eq!(*column_ix, 0); + }); + }); +} + +#[gpui::test] +fn table_cell_text_edit_emits_cell_scoped_semantic_command(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.insert_default_table(2, 2, cx); + editor.set_session_capture(true); + editor.select_table_cell_for_test(0, 0, 0, cx); + editor.insert_plain_text_from_toolkit("cell", cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let [SemanticEditCommand::ReplaceTableCell { + row_ix, + cell_ix, + cell, + .. + }] = edits[0].semantic_commands.as_slice() else { + panic!("expected one semantic ReplaceTableCell command, got {:?}", edits[0].semantic_commands); + }; + assert_eq!((*row_ix, *cell_ix), (0, 0)); + let InputTableCellBlock::Paragraph(paragraph) = &cell.blocks[0] else { + panic!("expected paragraph cell payload"); + }; + assert_eq!(input_paragraph_text(paragraph), "cell"); +} + +#[gpui::test] +fn own_collaboration_caret_color_can_be_toggled_off(cx: &mut gpui::TestAppContext) { + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(blank_document(), None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_own_collaboration_caret_color(Some(0x3b82f6), cx); + assert_eq!(editor.local_caret_color_rgb(), Some(0x3b82f6)); + editor.set_show_own_collaboration_caret_color(false, cx); + assert_eq!(editor.local_caret_color_rgb(), None); + editor.set_show_own_collaboration_caret_color(true, cx); + assert_eq!(editor.local_caret_color_rgb(), Some(0x3b82f6)); + }); + }); +} + +#[gpui::test] +fn text_entry_in_selected_equation_updates_equation_only(cx: &mut gpui::TestAppContext) { + let mut document = document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("body")], + }], + ); + document.blocks = std::sync::Arc::new(vec![ + Block::Paragraph(document.paragraphs[0].clone()), + Block::Equation(EquationBlock { + source: "x".into(), + syntax: EquationSyntax::Latex, + display: EquationDisplay::Display, + version: 0, + }), + ]); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + let (document, edits) = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.select_equation_block_for_test(1, cx); + editor.insert_plain_text_from_toolkit("+1", cx); + editor.replace_selected_text_from_platform_for_test("+2", cx); + (editor.document().clone(), editor.take_pending_session_edits()) + }) + }); + + assert_eq!(paragraph_text(&document, 0), "body"); + let Block::Equation(equation) = &document.blocks[1] else { + panic!("expected equation block after toolkit text insert"); + }; + assert_eq!(equation.source.as_ref(), "x+1+2"); + assert_eq!(edits.len(), 2); + let [SemanticEditCommand::ReplaceEquationSourceRange { range, text, .. }] = edits[0].semantic_commands.as_slice() else { + panic!("expected first edit to replace an equation source range, got {:?}", edits[0].semantic_commands); + }; + assert_eq!(range.clone(), 1..1); + assert_eq!(text, "+1"); + let [SemanticEditCommand::ReplaceEquationSourceRange { range, text, .. }] = edits[1].semantic_commands.as_slice() else { + panic!("expected second edit to replace an equation source range, got {:?}", edits[1].semantic_commands); + }; + assert_eq!(range.clone(), 3..3); + assert_eq!(text, "+2"); +} + +#[gpui::test] +fn image_alt_text_edit_emits_alt_text_semantic_command(cx: &mut gpui::TestAppContext) { + let mut document = document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("body")], + }], + ); + document.blocks = std::sync::Arc::new(vec![ + Block::Paragraph(document.paragraphs[0].clone()), + Block::Image(ImageBlock { + asset_id: AssetId(7), + alt_text: "old".into(), + caption: None, + sizing: ImageSizing::Intrinsic, + alignment: BlockAlignment::Left, + version: 0, + }), + ]); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + let (document, edits) = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.select_image_block_for_test(1, cx); + editor.set_selected_image_alt_text("new alt", cx); + (editor.document().clone(), editor.take_pending_session_edits()) + }) + }); + + let Block::Image(image) = &document.blocks[1] else { + panic!("expected image block after alt edit"); + }; + assert_eq!(image.alt_text.as_ref(), "new alt"); + assert_eq!(edits.len(), 1); + let [SemanticEditCommand::ReplaceImageAltText { text, .. }] = edits[0].semantic_commands.as_slice() else { + panic!("expected image alt semantic command, got {:?}", edits[0].semantic_commands); + }; + assert_eq!(text, "new alt"); +} + +#[gpui::test] +fn image_layout_edit_emits_layout_semantic_command(cx: &mut gpui::TestAppContext) { + let mut document = document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("body")], + }], + ); + document.blocks = std::sync::Arc::new(vec![ + Block::Paragraph(document.paragraphs[0].clone()), + Block::Image(ImageBlock { + asset_id: AssetId(7), + alt_text: "alt".into(), + caption: None, + sizing: ImageSizing::Intrinsic, + alignment: BlockAlignment::Left, + version: 0, + }), + ]); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.select_image_block_for_test(1, cx); + editor.set_selected_image_fit_width(cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let [SemanticEditCommand::SetImageLayout { sizing, alignment, .. }] = edits[0].semantic_commands.as_slice() else { + panic!("expected image layout semantic command, got {:?}", edits[0].semantic_commands); + }; + assert!(matches!(sizing, InputImageSizing::FitWidth)); + assert!(matches!(alignment, InputBlockAlignment::Left)); +} + +#[gpui::test] +fn deleting_selection_across_object_emits_structured_semantic_commands(cx: &mut gpui::TestAppContext) { + let mut document = document_from_input( + DocumentTheme::default(), + vec![ + InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("alpha")], + }, + InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("omega")], + }, + ], + ); + document.blocks = std::sync::Arc::new(vec![ + Block::Paragraph(document.paragraphs[0].clone()), + Block::Image(ImageBlock { + asset_id: AssetId(7), + alt_text: "alt".into(), + caption: None, + sizing: ImageSizing::Intrinsic, + alignment: BlockAlignment::Left, + version: 0, + }), + Block::Paragraph(document.paragraphs[1].clone()), + ]); + let first_block_id = document.ids.block_ids.first().copied().unwrap_or(BlockId(1)); + let second_block_id = document.ids.block_ids.get(1).copied().unwrap_or(BlockId(2)); + document.ids.block_ids = vec![first_block_id, BlockId(7), second_block_id]; + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + let edits = cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_session_capture(true); + editor.set_text_selection_for_test( + DocumentOffset { paragraph: 0, byte: 2 }, + DocumentOffset { paragraph: 1, byte: 2 }, + cx, + ); + editor.cut(cx); + editor.take_pending_session_edits() + }) + }); + + assert_eq!(edits.len(), 1); + let commands = edits[0].semantic_commands.as_slice(); + assert_eq!(commands.len(), 2); + assert!(matches!(commands[0], SemanticEditCommand::DeleteBlock { .. })); + assert!(matches!(commands[1], SemanticEditCommand::ReplaceParagraphSpan { .. })); +} + +#[gpui::test] +fn stale_selection_style_commands_do_not_panic(cx: &mut gpui::TestAppContext) { + let document = document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("alpha")], + }], + ); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_text_selection_for_test( + DocumentOffset { paragraph: 1, byte: 0 }, + DocumentOffset { paragraph: 1, byte: 0 }, + cx, + ); + editor.toggle_semantic_style_for_selection(RunSemanticStyle::Custom(2), cx); + editor.insert_plain_text_from_toolkit("z", cx); + assert_eq!(paragraph_text(editor.document(), 0), "alpha"); + }); + }); +} + +#[gpui::test] +fn select_all_emits_selection_changed_once(cx: &mut gpui::TestAppContext) { + let document = document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("alpha beta")], + }], + ); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + let selections = Rc::new(RefCell::new(Vec::new())); + let recorder_selections = selections.clone(); + let _recorder = cx.update(|cx| { + let editor = editor.clone(); + cx.new(|cx| SelectionEventRecorder { + selections: recorder_selections, + _subscription: cx.subscribe(&editor, |recorder: &mut SelectionEventRecorder, _, event: &EditorEvent, _| { + if let EditorEvent::SelectionChanged { selection } = event { + recorder.selections.borrow_mut().push(selection.clone()); + } + }), + }) + }); + + cx.update(|cx| editor.update(cx, |editor, cx| editor.select_all(cx))); + let first_events = selections.borrow(); + assert_eq!(first_events.len(), 1); + assert_eq!( + first_events[0].normalized(), + DocumentOffset { paragraph: 0, byte: 0 }..DocumentOffset { + paragraph: 0, + byte: "alpha beta".len(), + } + ); + drop(first_events); + + cx.update(|cx| editor.update(cx, |editor, cx| editor.select_all(cx))); + assert_eq!(selections.borrow().len(), 1); +} + +#[gpui::test] +fn runtime_acknowledgement_preserves_newer_optimistic_input_and_rebases_it(cx: &mut gpui::TestAppContext) { + let mut document = blank_document(); + document.frontier = vec![1, 2, 3]; + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_runtime_capture(true); + assert!(editor.insert_single_grapheme_fast_path("a", cx)); + let flushed = editor.take_pending_runtime_edits(); + assert_eq!(flushed.len(), 1); + let acknowledged_selection = flushed[0].selection_after.clone(); + editor.begin_runtime_edit(); + + assert!(editor.insert_single_grapheme_fast_path("b", cx)); + assert_eq!(paragraph_text(editor.document(), 0), "ab"); + assert_eq!(editor.selection.head.byte, 2); + + editor.acknowledge_runtime_edit(vec![4, 5, 6], acknowledged_selection, cx); + + assert_eq!(paragraph_text(editor.document(), 0), "ab"); + assert_eq!(editor.selection.head.byte, 2); + assert!(!editor.runtime_edit_in_flight()); + let queued = editor.take_pending_runtime_edits(); + assert_eq!(queued.len(), 1); + assert_eq!(queued[0].base_frontier, vec![4, 5, 6]); + let [SemanticEditCommand::InsertText { at, text, .. }] = queued[0].semantic_commands.as_slice() else { + panic!("expected one queued insert command"); + }; + assert_eq!(*at, DocumentOffset { paragraph: 0, byte: 1 }); + assert_eq!(text, "b"); + }); + }); +} + +#[gpui::test] +fn structural_runtime_acknowledgement_replays_newer_optimistic_input_and_rebases_it(cx: &mut gpui::TestAppContext) { + let mut document = document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("a")], + }], + ); + document.frontier = vec![1, 2, 3]; + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + editor.set_runtime_capture(true); + editor.set_text_selection_for_test( + DocumentOffset { paragraph: 0, byte: 1 }, + DocumentOffset { paragraph: 0, byte: 1 }, + cx, + ); + editor.insert_paragraph_break_command(cx); + let flushed = editor.take_pending_runtime_edits(); + assert_eq!(flushed.len(), 1); + assert!(matches!( + flushed[0].semantic_commands.as_slice(), + [SemanticEditCommand::SplitParagraph { .. }] + )); + let acknowledged_selection = flushed[0].selection_after.clone(); + editor.begin_runtime_edit(); + + assert!(editor.insert_single_grapheme_fast_path("b", cx)); + assert!(editor.insert_single_grapheme_fast_path("c", cx)); + assert!(editor.insert_single_grapheme_fast_path("d", cx)); + assert_eq!(paragraph_text(editor.document(), 0), "a"); + assert_eq!(paragraph_text(editor.document(), 1), "bcd"); + assert_eq!(editor.selection.head, DocumentOffset { paragraph: 1, byte: 3 }); + + let mut canonical = document_from_input( + DocumentTheme::default(), + vec![ + InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("a")], + }, + InputParagraph { + style: ParagraphStyle::Normal, + runs: Vec::new(), + }, + ], + ); + canonical.frontier = vec![4, 5, 6]; + editor.replace_document_projection_replaying_pending(canonical, Vec::new(), acknowledged_selection, cx); + + assert_eq!(paragraph_text(editor.document(), 0), "a"); + assert_eq!(paragraph_text(editor.document(), 1), "bcd"); + assert_eq!(editor.selection.head, DocumentOffset { paragraph: 1, byte: 3 }); + assert!(!editor.runtime_edit_in_flight()); + let queued = editor.take_pending_runtime_edits(); + assert_eq!(queued.len(), 3); + for edit in &queued { + assert_eq!(edit.base_frontier, vec![4, 5, 6]); + } + for (edit, (expected_text, expected_byte)) in queued.iter().zip([("b", 0), ("c", 1), ("d", 2)]) { + let [SemanticEditCommand::InsertText { at, text, .. }] = edit.semantic_commands.as_slice() else { + panic!("expected one queued insert command"); + }; + assert_eq!(*at, DocumentOffset { paragraph: 1, byte: expected_byte }); + assert_eq!(text, expected_text); + } + }); + }); +} + +#[gpui::test] +fn text_input_floors_stale_interior_utf8_caret_to_a_character_boundary(cx: &mut gpui::TestAppContext) { + let document = document_from_input_blocks( + DocumentTheme::default(), + vec![InputBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![plain("a’b")], + })], + ); + let editor = cx.update(|cx| cx.new(|cx| RichTextEditor::new_with_path(document, None, cx))); + + cx.update(|cx| { + editor.update(cx, |editor, cx| { + // The curly apostrophe occupies bytes 1..4. Byte 2 is deliberately an + // invalid stale caret inside that scalar value. + editor.selection = EditorSelection::collapsed(DocumentOffset { paragraph: 0, byte: 2 }); + editor.insert_text_command("x", cx); + + assert_eq!(paragraph_text(editor.document(), 0), "ax’b"); + assert_eq!(editor.selection.head, DocumentOffset { paragraph: 0, byte: 2 }); + assert!(editor.document().text.is_char_boundary(global_byte(editor.document(), editor.selection.head))); + }); + }); +} diff --git a/crates/gpui-flowtext/src/rich_text/tests/edit_layout.rs b/crates/gpui-flowtext/src/rich_text/tests/edit_layout.rs index ba997a0e..d999d47b 100644 --- a/crates/gpui-flowtext/src/rich_text/tests/edit_layout.rs +++ b/crates/gpui-flowtext/src/rich_text/tests/edit_layout.rs @@ -1,9 +1,5 @@ use super::*; use gpui::{Bounds, black, point, px, size}; -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, -}; #[test] #[hotpath::measure] diff --git a/crates/gpui-flowtext/src/rich_text/tests/history_tables.rs b/crates/gpui-flowtext/src/rich_text/tests/history_tables.rs index 7d1f3fcf..c931981d 100644 --- a/crates/gpui-flowtext/src/rich_text/tests/history_tables.rs +++ b/crates/gpui-flowtext/src/rich_text/tests/history_tables.rs @@ -233,55 +233,6 @@ fn replace_block_operation_undo_redo_preserves_equation_source_changes() { assert_eq!(equation.source.as_ref(), "x"); } -#[test] -#[hotpath::measure] -fn default_inserted_table_shape_round_trips_through_document() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("body")], - }], - ); - let table = Block::Table(TableBlock { - rows: (0..2) - .map(|_| TableRow { - cells: (0..2) - .map(|_| TableCell { - blocks: vec![TableCellBlock::Paragraph(TableCellParagraph { - paragraph: Paragraph { - style: ParagraphStyle::Normal, - byte_range: 0..0, - runs: Vec::new(), - version: 0, - }, - text: String::new(), - })], - row_span: 1, - col_span: 1, - }) - .collect(), - }) - .collect(), - column_widths: vec![TableColumnWidth::Fraction(1), TableColumnWidth::Fraction(1)], - style: TableStyle { header_row: false }, - version: 0, - }); - document.blocks = std::sync::Arc::new(vec![Block::Paragraph(document.paragraphs[0].clone()), table]); - - let path = std::env::temp_dir().join(format!("flowtext-default-table-{}.document", uuid::Uuid::new_v4())); - write_document(&path, &document).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - let Block::Table(table) = &loaded.blocks[1] else { - panic!("expected table block"); - }; - assert_eq!(table.rows.len(), 2); - assert!(table.rows.iter().all(|row| row.cells.len() == 2)); - assert_eq!(table.column_widths.len(), 2); -} - #[test] #[hotpath::measure] fn table_cell_paragraph_clipboard_conversion_preserves_text_and_styles() { diff --git a/crates/gpui-flowtext/src/rich_text/tests/mod.rs b/crates/gpui-flowtext/src/rich_text/tests/mod.rs index cebe5a31..22000edd 100644 --- a/crates/gpui-flowtext/src/rich_text/tests/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/tests/mod.rs @@ -1,7 +1,7 @@ include!("edit_layout.rs"); include!("selection.rs"); -include!("persistence_blocks.rs"); include!("history_tables.rs"); include!("positions_formatting.rs"); include!("history_fragments.rs"); include!("decorations_drag_search.rs"); +include!("collab_capture.rs"); diff --git a/crates/gpui-flowtext/src/rich_text/tests/persistence_blocks.rs b/crates/gpui-flowtext/src/rich_text/tests/persistence_blocks.rs deleted file mode 100644 index eacb1739..00000000 --- a/crates/gpui-flowtext/src/rich_text/tests/persistence_blocks.rs +++ /dev/null @@ -1,385 +0,0 @@ - -#[test] -#[hotpath::measure] -fn single_paragraph_edits_keep_following_derived_byte_ranges_current() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![ - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("first")], - }, - InputParagraph { - style: ParagraphStyle::Custom(0), - runs: vec![plain("second")], - }, - ], - ); - - insert_text_at(&mut document, 0, "first".len(), " extended", RunStyles::default()); - - let range = paragraph_byte_range(&document, 1); - assert_eq!(document_text_slice(&document, range.clone()), "second"); - assert!(range.end <= document.text.byte_len()); -} - -#[test] -#[hotpath::measure] -fn document_round_trip_preserves_text_structure_and_styles() { - let document = demo_document(); - let dir = std::env::temp_dir(); - let path = dir.join(format!("flowtext-test-{}.document", std::process::id())); - write_document(&path, &document).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!( - document_text_slice(&document, 0..document.text.byte_len()), - document_text_slice(&loaded, 0..loaded.text.byte_len()) - ); - assert_eq!(document.paragraphs.len(), loaded.paragraphs.len()); - // Verify styles and run structure for every paragraph, not just the first. - for (ix, (orig, loaded_para)) in document - .paragraphs - .iter() - .zip(loaded.paragraphs.iter()) - .enumerate() - { - assert_eq!(orig.style, loaded_para.style, "paragraph {ix} style mismatch"); - assert_eq!(orig.runs, loaded_para.runs, "paragraph {ix} runs mismatch"); - } -} - -#[test] -#[hotpath::measure] -fn split_and_merge_preserve_empty_styled_paragraphs() { - let spoken = RunStyles::default().with(RunStyle::Highlight(1)); - let mut document = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Custom(0), - runs: vec![run("Custom heading", spoken)], - }], - ); - - let first_len = paragraph_text_len(&document.paragraphs[0]); - split_paragraph_at(&mut document, 0, first_len); - assert_eq!(document.paragraphs.len(), 2); - assert_eq!(document.paragraphs[1].style, ParagraphStyle::Custom(0)); - assert_eq!(paragraph_text_len(&document.paragraphs[1]), 0); - assert!(document.paragraphs[1].runs.is_empty()); - - let join_byte = paragraph_text_len(&document.paragraphs[0]); - delete_cross_paragraph_range( - &mut document, - DocumentOffset { - paragraph: 0, - byte: join_byte, - }..DocumentOffset { paragraph: 1, byte: 0 }, - ); - assert_eq!(document.paragraphs.len(), 1); - assert_eq!(paragraph_text(&document, 0), "Custom heading"); - assert_eq!( - document.paragraphs[0].runs, - vec![TextRun { - len: "Custom heading".len(), - styles: spoken - }] - ); -} - -#[test] -#[hotpath::measure] -fn document_round_trip_preserves_empty_styled_paragraphs() { - let document = document_from_input( - DocumentTheme::default(), - vec![ - InputParagraph { - style: ParagraphStyle::Custom(0), - runs: Vec::new(), - }, - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("body")], - }, - ], - ); - let path = std::env::temp_dir().join(format!("flowtext-empty-{}.document", std::process::id())); - write_document(&path, &document).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!(loaded.paragraphs[0].style, ParagraphStyle::Custom(0)); - assert_eq!(paragraph_text_len(&loaded.paragraphs[0]), 0); - assert!(loaded.paragraphs[0].runs.is_empty()); - assert_eq!(paragraph_text(&loaded, 1), "body"); -} - -#[test] -#[hotpath::measure] -fn document_v4_round_trip_preserves_mixed_block_order_and_assets() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![ - InputParagraph { - style: ParagraphStyle::Custom(0), - runs: vec![plain("Heading")], - }, - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("After image")], - }, - ], - ); - let asset_id = AssetId(42); - let asset_bytes = vec![1, 2, 3, 4]; - let mut hasher = DefaultHasher::new(); - asset_bytes.hash(&mut hasher); - document.assets.assets.insert( - asset_id, - AssetRecord { - id: asset_id, - mime_type: "image/png".into(), - original_name: Some("figure.png".into()), - content_hash: hasher.finish(), - bytes: std::sync::Arc::new(asset_bytes), - }, - ); - document.blocks = std::sync::Arc::new(vec![ - Block::Paragraph(document.paragraphs[0].clone()), - Block::Image(ImageBlock { - asset_id, - alt_text: "figure".into(), - caption: None, - sizing: ImageSizing::FitWidth, - alignment: BlockAlignment::Center, - version: 0, - }), - Block::Paragraph(document.paragraphs[1].clone()), - Block::Equation(EquationBlock { - source: "x^2 + y^2 = z^2".into(), - syntax: EquationSyntax::Latex, - display: EquationDisplay::Display, - version: 0, - }), - ]); - - let path = std::env::temp_dir().join(format!("flowtext-blocks-{}.document", uuid::Uuid::new_v4())); - write_document(&path, &document).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!(loaded.blocks.len(), 4); - assert!(matches!(loaded.blocks[0], Block::Paragraph(_))); - assert!(matches!(loaded.blocks[1], Block::Image(_))); - assert!(matches!(loaded.blocks[2], Block::Paragraph(_))); - assert!(matches!(loaded.blocks[3], Block::Equation(_))); - assert_eq!(loaded.assets.assets[&asset_id].bytes.as_slice(), &[1, 2, 3, 4]); -} - -#[test] -#[hotpath::measure] -fn image_fit_width_layout_uses_asset_aspect_ratio() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("body")], - }], - ); - let asset_id = AssetId(7); - document.assets.assets.insert( - asset_id, - AssetRecord { - id: asset_id, - mime_type: "image/png".into(), - original_name: None, - content_hash: 0, - bytes: std::sync::Arc::new(test_png_2x1()), - }, - ); - let image = ImageBlock { - asset_id, - alt_text: "".into(), - caption: None, - sizing: ImageSizing::FitWidth, - alignment: BlockAlignment::Left, - version: 0, - }; - - let width = document.theme.pageless_inset_x * 2.0 + px(200.0); - assert_eq!(image_layout_height_for_test(&document, &image, width), px(100.0)); -} - -#[test] -#[hotpath::measure] -fn paragraph_sync_preserves_non_text_blocks_when_paragraphs_are_removed() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![ - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("before")], - }, - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("after")], - }, - ], - ); - let image = Block::Image(ImageBlock { - asset_id: AssetId(99), - alt_text: "image".into(), - caption: None, - sizing: ImageSizing::FitWidth, - alignment: BlockAlignment::Center, - version: 0, - }); - document.blocks = std::sync::Arc::new(vec![ - Block::Paragraph(document.paragraphs[0].clone()), - image.clone(), - Block::Paragraph(document.paragraphs[1].clone()), - ]); - - let current = capture_document_span(&document, 0..2); - apply_document_span_replacement( - &mut document, - ¤t, - &DocumentSpan { - start_paragraph: 0, - text: "after".to_string(), - paragraphs: vec![Paragraph { - style: ParagraphStyle::Normal, - byte_range: 0.."after".len(), - runs: vec![TextRun { - len: "after".len(), - styles: RunStyles::default(), - }], - version: 0, - }], - }, - ); - - assert_eq!(document.paragraphs.len(), 1); - assert!( - document - .blocks - .iter() - .any(|block| matches!(block, Block::Image(_))) - ); -} - -#[test] -#[hotpath::measure] -fn deleting_empty_paragraph_above_image_keeps_image_before_next_paragraph() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![ - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("before")], - }, - InputParagraph { - style: ParagraphStyle::Normal, - runs: Vec::new(), - }, - InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("after")], - }, - ], - ); - let image = Block::Image(ImageBlock { - asset_id: AssetId(100), - alt_text: "image".into(), - caption: None, - sizing: ImageSizing::FitWidth, - alignment: BlockAlignment::Center, - version: 0, - }); - document.blocks = std::sync::Arc::new(vec![ - Block::Paragraph(document.paragraphs[0].clone()), - Block::Paragraph(document.paragraphs[1].clone()), - image, - Block::Paragraph(document.paragraphs[2].clone()), - ]); - - delete_cross_paragraph_range( - &mut document, - DocumentOffset { - paragraph: 0, - byte: "before".len(), - }..DocumentOffset { paragraph: 1, byte: 0 }, - ); - - assert_eq!(document.paragraphs.len(), 2); - assert!(matches!(document.blocks[0], Block::Paragraph(_))); - assert!(matches!(document.blocks[1], Block::Image(_))); - assert!(matches!(document.blocks[2], Block::Paragraph(_))); -} - -#[hotpath::measure] -fn test_png_2x1() -> Vec { - vec![ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 1, 8, 6, 0, 0, 0, 244, 34, 127, 138, 0, 0, 0, 12, 73, 68, - 65, 84, 8, 29, 99, 248, 15, 4, 0, 9, 251, 3, 253, 167, 170, 43, 113, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, - ] -} - -#[test] -#[hotpath::measure] -fn document_v4_round_trip_preserves_table_cell_paragraph_and_run_styles() { - let emphasized = RunStyles::default() - .with(RunStyle::Semantic(2)) - .with(RunStyle::Highlight(1)); - let cell_paragraph = Paragraph { - style: ParagraphStyle::Custom(3), - byte_range: 0.."cell".len(), - runs: vec![TextRun { - len: "cell".len(), - styles: emphasized, - }], - version: 0, - }; - let mut document = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("before")], - }], - ); - document.blocks = std::sync::Arc::new(vec![ - Block::Paragraph(document.paragraphs[0].clone()), - Block::Table(TableBlock { - rows: vec![TableRow { - cells: vec![TableCell { - blocks: vec![TableCellBlock::Paragraph(TableCellParagraph { - paragraph: cell_paragraph.clone(), - text: "cell".to_string(), - })], - row_span: 1, - col_span: 1, - }], - }], - column_widths: vec![TableColumnWidth::Fraction(1)], - style: TableStyle { header_row: true }, - version: 0, - }), - ]); - - let path = std::env::temp_dir().join(format!("flowtext-table-{}.document", uuid::Uuid::new_v4())); - write_document(&path, &document).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - let Block::Table(table) = &loaded.blocks[1] else { - panic!("expected table block"); - }; - assert!(table.style.header_row); - let TableCellBlock::Paragraph(loaded_paragraph) = &table.rows[0].cells[0].blocks[0] else { - panic!("expected table-cell paragraph"); - }; - assert_eq!(loaded_paragraph.paragraph.style, ParagraphStyle::Custom(3)); - assert_eq!(loaded_paragraph.paragraph.runs, cell_paragraph.runs); - assert_eq!(loaded_paragraph.text, "cell"); -} diff --git a/crates/gpui-flowtext/src/rich_text/tests/positions_formatting.rs b/crates/gpui-flowtext/src/rich_text/tests/positions_formatting.rs index a207de5c..5af08f8c 100644 --- a/crates/gpui-flowtext/src/rich_text/tests/positions_formatting.rs +++ b/crates/gpui-flowtext/src/rich_text/tests/positions_formatting.rs @@ -44,37 +44,6 @@ fn document_position_round_trips_top_level_text_blocks() { ); } -#[test] -#[hotpath::measure] -fn document_validation_rejects_zero_sized_fixed_images() { - let mut document = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("body")], - }], - ); - document.blocks = std::sync::Arc::new(vec![ - Block::Paragraph(document.paragraphs[0].clone()), - Block::Image(ImageBlock { - asset_id: AssetId(99), - alt_text: "invalid".into(), - caption: None, - sizing: ImageSizing::Fixed { - width_px: 0, - height_px: None, - }, - alignment: BlockAlignment::Left, - version: 0, - }), - ]); - - let path = std::env::temp_dir().join(format!("flowtext-invalid-image-{}.document", uuid::Uuid::new_v4())); - let error = write_document(&path, &document).unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - let _ = std::fs::remove_file(path); -} - #[test] #[hotpath::measure] fn double_click_at_text_paragraph_end_selects_only_that_paragraph() { @@ -102,13 +71,13 @@ fn double_click_at_text_paragraph_end_selects_only_that_paragraph() { assert_eq!( selection, - EditorSelection { - anchor: DocumentOffset { paragraph: 0, byte: 0 }, - head: DocumentOffset { + EditorSelection::range( + DocumentOffset { paragraph: 0, byte: 0 }, + DocumentOffset { paragraph: 0, byte: "first paragraph".len(), }, - } + ) ); } @@ -137,10 +106,7 @@ fn double_click_empty_paragraph_selects_only_empty_paragraph() { assert_eq!( selection, - EditorSelection { - anchor: DocumentOffset { paragraph: 1, byte: 0 }, - head: DocumentOffset { paragraph: 1, byte: 0 }, - } + EditorSelection::collapsed(DocumentOffset { paragraph: 1, byte: 0 }) ); } @@ -222,58 +188,3 @@ fn semantic_run_styles_are_mutually_exclusive() { styles.apply(RunStyle::Semantic(5)); assert_eq!(styles.semantic, RunSemanticStyle::Custom(5)); } - -#[test] -#[hotpath::measure] -fn document_round_trip_preserves_condensed_semantic_styles() { - let path = std::env::temp_dir().join(format!("flowtext-semantic-{}.document", uuid::Uuid::new_v4())); - let document = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![ - run("condensed", RunStyles::default().with(RunStyle::Semantic(4))), - run( - " ultra", - RunStyles::default() - .with(RunStyle::Semantic(5)) - .with(RunStyle::Highlight(1)), - ), - ], - }], - ); - write_document(&path, &document).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!(loaded.paragraphs[0].runs[0].styles.semantic, RunSemanticStyle::Custom(4)); - assert_eq!(loaded.paragraphs[0].runs[1].styles.semantic, RunSemanticStyle::Custom(5)); - assert_eq!(loaded.paragraphs[0].runs[1].styles.highlight, Some(HighlightStyle::Custom(1))); -} - -#[test] -#[hotpath::measure] -fn document_save_can_replace_existing_file() { - let path = std::env::temp_dir().join(format!("flowtext-replace-{}.document", uuid::Uuid::new_v4())); - let first = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("first")], - }], - ); - let second = document_from_input( - DocumentTheme::default(), - vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![plain("second")], - }], - ); - - write_document(&path, &first).unwrap(); - write_document(&path, &second).unwrap(); - let loaded = read_document(&path).unwrap(); - let _ = std::fs::remove_file(path); - - assert_eq!(paragraph_text(&loaded, 0), "second"); -} diff --git a/crates/gpui-flowtext/src/rich_text/word_boundary.rs b/crates/gpui-flowtext/src/rich_text/word_boundary.rs index 5486bede..e09eef3c 100644 --- a/crates/gpui-flowtext/src/rich_text/word_boundary.rs +++ b/crates/gpui-flowtext/src/rich_text/word_boundary.rs @@ -90,7 +90,7 @@ pub(super) fn next_debate_word_boundary_in_paragraph_text(text: &str, byte: usiz } #[hotpath::measure] -pub(super) fn previous_debate_word_boundary_in_document(document: &Document, offset: DocumentOffset) -> DocumentOffset { +pub(super) fn previous_debate_word_boundary_in_document(document: &DocumentProjection, offset: DocumentOffset) -> DocumentOffset { if document.paragraphs.is_empty() { return DocumentOffset::default(); } @@ -131,7 +131,7 @@ pub(super) fn previous_debate_word_boundary_in_document(document: &Document, off } #[hotpath::measure] -pub(super) fn next_debate_word_boundary_in_document(document: &Document, offset: DocumentOffset) -> DocumentOffset { +pub(super) fn next_debate_word_boundary_in_document(document: &DocumentProjection, offset: DocumentOffset) -> DocumentOffset { if document.paragraphs.is_empty() { return DocumentOffset::default(); } @@ -166,82 +166,73 @@ pub(super) fn next_debate_word_boundary_in_document(document: &Document, offset: } #[hotpath::measure] -pub(super) fn selection_for_word_at(document: &Document, offset: DocumentOffset) -> EditorSelection { +pub(super) fn selection_for_word_at(document: &DocumentProjection, offset: DocumentOffset) -> EditorSelection { let Some(paragraph) = document.paragraphs.get(offset.paragraph) else { - return EditorSelection { - anchor: DocumentOffset::default(), - head: DocumentOffset::default(), - }; + return EditorSelection::collapsed(DocumentOffset::default()); }; let paragraph_len = paragraph_text_len(paragraph); if paragraph_len == 0 || offset.byte >= paragraph_len { return selection_for_paragraph_at(document, offset.paragraph); } - EditorSelection { - anchor: previous_debate_word_boundary_in_document(document, offset), - head: next_debate_word_boundary_in_document(document, offset), - } + EditorSelection::range( + previous_debate_word_boundary_in_document(document, offset), + next_debate_word_boundary_in_document(document, offset), + ) } #[hotpath::measure] -pub(super) fn selection_for_paragraph_at(document: &Document, paragraph: usize) -> EditorSelection { +pub(super) fn selection_for_paragraph_at(document: &DocumentProjection, paragraph: usize) -> EditorSelection { let paragraph = paragraph.min(document.paragraphs.len().saturating_sub(1)); - EditorSelection { - anchor: DocumentOffset { paragraph, byte: 0 }, - head: DocumentOffset { + EditorSelection::range( + DocumentOffset { paragraph, byte: 0 }, + DocumentOffset { paragraph, byte: paragraph_text_len(&document.paragraphs[paragraph]), }, - } + ) } #[hotpath::measure] pub(super) fn expand_drag_selection( - document: &Document, + document: &DocumentProjection, anchor: DocumentOffset, head: DocumentOffset, granularity: SelectionGranularity, ) -> EditorSelection { match granularity { - SelectionGranularity::Character => EditorSelection { anchor, head }, + SelectionGranularity::Character => EditorSelection::range(anchor, head), SelectionGranularity::Word => { let anchor_range = selection_for_word_at(document, anchor).normalized(); let head_range = selection_for_word_at(document, head).normalized(); if head < anchor { - EditorSelection { - anchor: anchor_range.end, - head: head_range.start, - } + EditorSelection::range(anchor_range.end, head_range.start) } else { - EditorSelection { - anchor: anchor_range.start, - head: head_range.end, - } + EditorSelection::range(anchor_range.start, head_range.end) } }, SelectionGranularity::Paragraph => { if head < anchor { - EditorSelection { - anchor: DocumentOffset { + EditorSelection::range( + DocumentOffset { paragraph: anchor.paragraph, byte: paragraph_text_len(&document.paragraphs[anchor.paragraph]), }, - head: DocumentOffset { + DocumentOffset { paragraph: head.paragraph, byte: 0, }, - } + ) } else { - EditorSelection { - anchor: DocumentOffset { + EditorSelection::range( + DocumentOffset { paragraph: anchor.paragraph, byte: 0, }, - head: DocumentOffset { + DocumentOffset { paragraph: head.paragraph, byte: paragraph_text_len(&document.paragraphs[head.paragraph]), }, - } + ) } }, } @@ -250,7 +241,7 @@ pub(super) fn expand_drag_selection( // Grapheme-cluster-aware step backwards. Handles combining marks and // compound emoji correctly, so one keystroke deletes one visible character. #[hotpath::measure] -pub(super) fn prev_grapheme_boundary_in_paragraph(document: &Document, paragraph_ix: usize, byte: usize) -> usize { +pub(super) fn prev_grapheme_boundary_in_paragraph(document: &DocumentProjection, paragraph_ix: usize, byte: usize) -> usize { if byte == 0 { return 0; } @@ -265,7 +256,7 @@ pub(super) fn prev_grapheme_boundary_in_paragraph(document: &Document, paragraph } #[hotpath::measure] -pub(super) fn next_grapheme_boundary_in_paragraph(document: &Document, paragraph_ix: usize, byte: usize) -> usize { +pub(super) fn next_grapheme_boundary_in_paragraph(document: &DocumentProjection, paragraph_ix: usize, byte: usize) -> usize { let paragraph = &document.paragraphs[paragraph_ix]; let len = paragraph_text_len(paragraph); if byte >= len { @@ -279,7 +270,7 @@ pub(super) fn next_grapheme_boundary_in_paragraph(document: &Document, paragraph } #[hotpath::measure] -fn paragraph_byte_at(document: &Document, paragraph_ix: usize, byte: usize) -> Option { +fn paragraph_byte_at(document: &DocumentProjection, paragraph_ix: usize, byte: usize) -> Option { let paragraph = document.paragraphs.get(paragraph_ix)?; (byte < paragraph_text_len(paragraph)).then(|| { document diff --git a/empty_paragraph_bug_option_plans.md b/empty_paragraph_bug_option_plans.md new file mode 100644 index 00000000..1df2d8da --- /dev/null +++ b/empty_paragraph_bug_option_plans.md @@ -0,0 +1,1254 @@ +# Empty-paragraph boundary bug option plans + +Collected plan outputs for Options 5, 3, 2, and 1. + +## Option 5 + +## Summary + +Introduce explicit CRDT-level **paragraph split/join operations** and push the system to use them instead of implicit newline-based paragraph inference when the caret is in “empty paragraph” states. The goal is to eliminate ambiguous paragraph boundaries by making paragraph transitions first-class commands in the editor pipeline, runtime reducer, and projection layer. + +This fixes the failure mode where an empty paragraph is treated inconsistently depending on caret adjacency and causes incorrect insert/mutation targeting. + +--- + +## Changes + +### 1. Command model (core semantic layer) + +**File (likely):** +- `src/editor/commands.rs` +- `src/editor/command_model.rs` +- `src/crdt/commands.rs` + +#### Add explicit commands + +Introduce two new semantic commands: + +```rust +SplitParagraph { + at: Cursor, + source_paragraph_id: ParagraphId, +} + +JoinParagraph { + left_paragraph_id: ParagraphId, + right_paragraph_id: ParagraphId, +} +``` + +#### Modify existing InsertText behavior + +Existing: + +- InsertText at caret implicitly triggers paragraph boundary creation when newline exists. + +Change: + +- If insert includes `\n` OR caret is in empty paragraph context → DO NOT infer split. +- Instead emit: + +```rust +SplitParagraph { at, source_paragraph_id } +``` + +Then emit remaining text insert into new paragraph. + +#### Add invariant + +- Paragraph boundaries MUST be represented by CRDT node split, not text content. + +--- + +### 2. Editor pipeline (input → command generation) + +**File (likely):** +- `src/editor/input.rs` +- `src/editor/transaction_builder.rs` +- `src/editor/keymap.rs` + +#### Change newline handling + +Current behavior [INFERENCE]: +- newline → insert `\n` into text node + +Replace: + +- newline key ALWAYS produces semantic command: + +```rust +Key::Enter → SplitParagraph +``` + +BUT with conditional refinement: + +If caret is: +- at end of non-empty paragraph → split normally +- inside empty paragraph → still split (no-op-safe) +- between empty paragraph + next text → ensure stable split target selection + +#### Add caret context classification + +Introduce helper: + +```rust +enum CaretContext { + InText, + EmptyParagraph, + BoundaryBetweenParagraphs, +} +``` + +Used to choose split/join behavior deterministically. + +--- + +### 3. Runtime execution layer (CRDT mutation) + +**File (likely):** +- `src/runtime/apply.rs` +- `src/crdt/runtime.rs` +- `src/crdt/paragraph.rs` + +#### Implement SplitParagraph + +Operation: + +1. Locate paragraph node in Loro doc graph +2. Split its text node at cursor offset +3. Create new paragraph CRDT node with: + - new id + - inserted sibling ordering link +4. Move trailing content into new node + +Key rule: + +- Empty paragraph split must still create a new node, even if both sides are empty + +Edge invariant: + +- Never allow "implicit empty paragraph identity collapse" + +#### Implement JoinParagraph + +Operation: + +1. Validate adjacency in paragraph graph +2. Merge right into left: + - append text + - reparent inline nodes +3. Delete right node from CRDT graph + +--- + +### 4. Projection layer (CRDT → editor view model) + +**File (likely):** +- `src/editor/projection.rs` +- `src/editor/view_model.rs` + +#### Update projection rules + +Current [INFERENCE]: +- paragraphs inferred from newline runs + +Replace: + +- paragraph list comes directly from CRDT paragraph nodes only + +Add rule: + +```text +Paragraph boundaries = explicit CRDT paragraph nodes only +NOT text parsing +``` + +#### Empty paragraph representation fix + +Fix: + +- empty paragraph MUST render as node, not collapse into "no paragraph" + +This is critical for bug reproduction case: +- caret on empty line before text + +Now that empty paragraph exists structurally, projection is stable. + +--- + +### 5. Edit command reconciliation (undo/redo / batching) + +**File (likely):** +- `src/editor/history.rs` +- `src/editor/transaction.rs` + +#### Treat split/join as atomic units + +- SplitParagraph is atomic (not split into insert + layout change) +- JoinParagraph is atomic inverse + +Undo stack: + +``` +SplitParagraph <-> JoinParagraph +``` + +--- + +### 6. Compatibility layer (existing semantic commands) + +**File (likely):** +- `src/editor/legacy_commands.rs` +- `src/editor/compat.rs` + +#### Backward compatibility mapping + +Old behavior: + +- InsertText("\n") → implicit paragraph break + +New mapping: + +```text +InsertText("\n") → SplitParagraph +``` + +But: + +- Keep InsertText semantics unchanged for non-newline text + +Add compatibility shim: + +- If external API still emits InsertText with embedded newlines: + - split into multiple SplitParagraph + InsertText segments + +--- + +### 7. Caret affinity + empty paragraph bug fix integration + +**File (likely):** +- `src/editor/caret.rs` +- `src/editor/selection.rs` + +#### Fix root issue + +Problem condition: + +- caret sits in empty paragraph node that is not structurally stable + +Fix: + +- caret ALWAYS anchors to paragraph node id + offset +- never to "visual line" + +Add rule: + +```text +Caret target = (ParagraphId, Offset) +NOT (TextNodeId, inferred line) +``` + +This removes ambiguity that triggers bug. + +--- + +### 8. Runtime paragraph normalization (migration safety) + +**File (likely):** +- `src/crdt/migrate.rs` +- `src/crdt/normalize.rs` + +Add migration pass: + +- Convert legacy newline-in-text paragraphs into explicit paragraph nodes + +Pseudo: + +```rust +for node in doc.text_nodes { + if node.contains('\n') { + split_into_paragraph_nodes(node); + } +} +``` + +--- + +## Sequence + +1. Add CRDT operations: + - SplitParagraph + - JoinParagraph + +2. Implement runtime CRDT mutation logic + +3. Update editor command generation (Enter key, InsertText) + +4. Update projection layer to rely only on paragraph nodes + +5. Fix caret anchoring model + +6. Update undo/redo semantics + +7. Add compatibility layer for legacy InsertText("\n") + +8. Add migration/normalization pass + +--- + +## Edge Cases + +- Empty document: + - must still allow SplitParagraph creating first paragraph node + +- Consecutive empty paragraphs: + - must preserve distinct nodes (no collapsing) + +- Caret at start of empty paragraph before text: + - SplitParagraph must NOT duplicate or drop paragraph + +- Undo of split at document start: + - must restore single paragraph node cleanly + +- Mixed legacy documents: + - newline-in-text + paragraph nodes coexist during migration window + +--- + +## Verification + +### Unit tests + +- split_empty_paragraph_creates_new_node() +- join_paragraph_removes_right_node() +- caret_in_empty_paragraph_is_stable_target() +- newline_key_produces_split_command() +- legacy_insert_text_newline_is_translated() + +### Integration tests + +- reproduce original bug: + - caret on empty line before text + - insert text → must target correct paragraph + +- document round-trip: + - split → join → split consistency + +### CRDT invariants + +- no orphan paragraph nodes +- no collapsed empty paragraph ambiguity +- projection always equals CRDT graph size + +--- + +## Critical Files + +Likely core touchpoints (must verify in repo): + +- `src/editor/commands.rs` +- `src/editor/input.rs` +- `src/editor/projection.rs` +- `src/editor/caret.rs` +- `src/editor/transaction.rs` +- `src/runtime/apply.rs` +- `src/crdt/paragraph.rs` +- `src/crdt/runtime.rs` +- `src/crdt/normalize.rs` + +--- + +## Cutover Strategy + +1. Introduce new commands alongside existing behavior (no removal yet) +2. Route Enter key → SplitParagraph in feature flag +3. Enable projection change behind flag +4. Enable caret anchoring change last +5. Remove legacy newline-in-text paragraph inference after stabilization + +--- + +If you want, next step is mapping this plan onto the actual Flowstate files once tool access is fixed, to replace all `[INFERENCE]` areas with exact symbol-level edits. + +## Option 3 + +## Summary + +Align “new document creation” with the same structural rules used when importing documents so that empty documents do not behave differently from parsed/imported ones. The fix is to remove ad-hoc initialization for brand-new docs and route everything through a single normalized “document seed” pipeline that guarantees identical paragraph/block invariants. + +--- + +## Changes + +### 1. Unify document initialization entrypoints +Target (likely [INFERENCE]): +- `src/document/mod.rs` +- `src/document/document.rs` +- `src/editor/session.rs` +- `src/editor/bootstrap.rs` + +Changes: +- Route all of these through one constructor: + - `Document::new_empty(...)` + - `Document::from_import(...)` + - `Document::open(...)` + +to a shared internal function: + +- `Document::init_from_seed(seed: DocumentSeed)` + +Add: + +```rust +struct DocumentSeed { + blocks: Vec, + origin: DocumentOrigin, // New | Import | Open +} +``` + +Rule: no constructor directly mutates CRDT state outside this path. + +--- + +### 2. Define canonical empty-document seed + +Target: +- `src/document/seed.rs` [INFERENCE new or existing module] + +Replace current “new doc” logic with: + +- Empty document MUST be: + +```text +blocks = [Paragraph { text: "" }] +``` + +NOT: +- empty tree +- missing root node +- implicit placeholder cursor node + +Invariant: + +- A document always has ≥1 block +- The first block is always a paragraph +- Paragraph text may be empty string + +--- + +### 3. Align imported-doc normalization to same seed pipeline + +Target: +- `src/document/import.rs` [INFERENCE] + +Change: +- Imported content is normalized into `Vec` then passed into same `DocumentSeed`. + +Rules: +- If import yields 0 blocks → convert to single empty paragraph +- If import yields trailing empty structure → preserve explicitly, not implicit editor state + +--- + +### 4. CRDT initialization consistency (Loro layer) + +Target: +- `src/crdt/loro_doc.rs` [INFERENCE] +- `src/crdt/schema.rs` [INFERENCE] + +Change: +- Ensure initial Loro tree is created from `DocumentSeed.blocks` only. +- Remove special-case branch like: + +```rust +if is_new_doc { insert_placeholder_paragraph() } +``` + +Replace with: + +- always apply same insert logic used by imports + +Invariant: + +- CRDT root children count == DocumentSeed.blocks.len() +- No implicit hidden node insertion for new docs + +--- + +### 5. Editor bootstrap alignment (caret + selection) + +Target: +- `src/editor/caret.rs` +- `src/editor/selection.rs` +- `src/editor/view.rs` [INFERENCE] + +Change: + +New rule for empty paragraph behavior: + +- caret position is ALWAYS: + - block_index = 0 + - offset = 0 + +No special-case “empty doc caret float” logic. + +Remove divergence: + +- imported docs and new docs must produce identical caret state after load + +Invariant: + +- `caret.doc_state(new_doc) == caret.doc_state(imported_doc(empty))` + +--- + +### 6. Empty-paragraph boundary semantics + +Target: +- `src/document/paragraph.rs` [INFERENCE] +- `src/runtime/paragraph_ops.rs` [INFERENCE] + +Fix alignment rule: + +Current bug class: +- empty paragraph treated as “non-boundary” +- or treated as synthetic separator + +New rule: + +- empty paragraph is a real block with valid boundaries +- cursor before/after empty paragraph MUST behave identically to imported empty paragraph + +Invariant: + +- boundary detection depends only on block structure, not origin + +--- + +### 7. Remove “new doc special casing” + +Search likely areas: +- `is_new_document` +- `if doc.is_empty()` +- `create_blank_document` + +Replace with: + +- `doc.origin == DocumentOrigin::New` should not affect structure +- origin is metadata only + +--- + +### 8. Migration strategy for existing docs + +Target: +- `src/storage/migrations.rs` [INFERENCE] + +Rules: + +- Existing persisted docs are NOT rewritten eagerly +- On load: + - normalize through `DocumentSeed::from_persisted` + - apply empty-doc rule only if document has zero blocks + +Migration logic: + +- version bump: `doc_schema_version += 1` +- lazy normalization at load time + +No bulk rewrite required. + +--- + +## Sequence + +1. Introduce `DocumentSeed` + canonical empty seed +2. Route all document constructors through `init_from_seed` +3. Align import pipeline to same seed system +4. Fix CRDT initialization to remove special-case new-doc logic +5. Align caret/selection bootstrap +6. Enforce empty-paragraph boundary invariants in runtime layer +7. Remove legacy “new doc” branching +8. Add migration normalization on load + +--- + +## Edge Cases + +- Completely empty persisted document (0 blocks) + - must become single empty paragraph +- Import producing malformed trailing empty nodes + - must normalize, not preserve broken structure +- Undo stack initialization for new docs + - must start from seed state, not post-hoc mutation +- Multi-paragraph imports vs new doc single paragraph equivalence +- Caret placed at end-of-document in empty doc +- Rapid open → edit before hydration completes + +--- + +## Verification + +Add behavioral tests (not snapshots of implementation internals): + +1. New doc parity test +- create empty doc +- create imported doc from `[]` +- assert identical block tree + +2. Caret invariance test +- both docs: + - caret at creation == (0,0) + +3. Empty paragraph boundary test +- insert empty paragraph between text blocks +- verify cursor movement across boundary identical for both doc types + +4. CRDT structure test +- assert root children count = 1 for empty docs + +5. Regression reproduction test +- scenario: + - new doc + - move caret to empty line + - ensure no boundary misclassification occurs + +--- + +## Critical Files + +[INFERENCE — must confirm in repo] +- `src/document/*` +- `src/editor/*` +- `src/crdt/loro_doc.rs` +- `src/runtime/paragraph_ops.rs` +- `src/document/import.rs` +- `src/storage/*` + +--- + +If you want, next step is mapping this plan onto exact Rust symbols by scanning the actual initialization paths (`Document::new`, `Editor::open_document`, CRDT root builder). + +## Option 2 + +## Summary + +Option 2 fixes caret/selection behavior when the caret is on an empty paragraph by making remapping boundary-aware. The core change is to treat empty paragraphs as valid positional anchors with explicit affinity rules, instead of collapsing/clamping them away during remap/projection. This prevents the caret from snapping into adjacent text paragraphs incorrectly when replaying edits or applying CRDT transforms. + +The fix spans caret representation, remap logic, paragraph projection, and empty-paragraph invariants in the CRDT layer. + +--- + +## Changes + +### 1. Caret / Selection model (core data) + +Likely impacted module (name inferred): +- `src/editor/selection.rs` [INFERENCE] +- `src/editor/caret.rs` [INFERENCE] + +#### Add explicit empty-paragraph anchor support + +Introduce or extend caret position enum: + +```rust +enum CaretPosition { + InText { + paragraph_id: ParagraphId, + offset: usize, + }, + + // NEW: explicit anchor for empty paragraphs + InEmptyParagraph { + paragraph_id: ParagraphId, + affinity: CaretAffinity, + }, +} +``` + +Selection stays the same but must allow zero-width range anchored in empty paragraphs. + +#### Affinity definition + +```rust +enum CaretAffinity { + Left, + Right, + Upstream, + Downstream, +} +``` + +Use this instead of implicit gravity. + +--- + +### 2. Remap / transform logic + +Likely module: +- `src/editor/remap.rs` [INFERENCE] +- `src/editor/transform.rs` [INFERENCE] + +#### Problem being fixed + +Current behavior likely: +- empty paragraph → no valid text index +- remap collapses it to nearest paragraph +- caret jumps incorrectly when replaying edits + +#### Fix: boundary-aware remap + +Introduce rule: + +```text +If paragraph is empty: + DO NOT clamp into neighbor text nodes + Preserve paragraph identity if it still exists in CRDT + Otherwise resolve via affinity +``` + +#### New remap function behavior + +Pseudo: + +```rust +fn remap_caret(pos: CaretPosition, tx: &Transform) -> CaretPosition { + match pos { + InText { .. } => remap_text_position(...), + + InEmptyParagraph { paragraph_id, affinity } => { + if tx.contains(paragraph_id) { + InEmptyParagraph { paragraph_id, affinity } + } else { + resolve_via_affinity(paragraph_id, affinity, tx) + } + } + } +} +``` + +--- + +### 3. Projection layer (CRDT → view) + +Likely module: +- `src/crdt/projection.rs` [INFERENCE] + +#### Issue + +Projection probably skips empty paragraphs or collapses them. + +#### Fix + +Empty paragraphs must emit a stable visible node: + +- Must produce a paragraph node with: + - valid id + - zero-length text + - caret anchor slot + +Add invariant: + +```rust +// MUST: empty paragraph still has projection entry +assert!(paragraph.is_empty() => paragraph.has_visible_anchor == true); +``` + +--- + +### 4. Edit replay / lifecycle + +Likely: +- `src/editor/lifecycle.rs` [INFERENCE] +- `src/editor/history.rs` [INFERENCE] + +#### Problem + +During replay: +- caret is applied after document mutations +- empty paragraph may disappear/reappear +- caret loses stable anchor + +#### Fix + +Introduce two-phase restore: + +1. Restore structural position (paragraph id based) +2. If missing: + - fallback to nearest structural sibling + - use affinity direction + +Pseudo: + +```rust +fn restore_caret(snapshot: CaretSnapshot, doc: &Doc) -> CaretPosition { + if let Some(p) = doc.find_paragraph(snapshot.paragraph_id) { + return match p.is_empty() { + true => CaretPosition::InEmptyParagraph { ... }, + false => CaretPosition::InText { ... } + } + } + + resolve_missing_paragraph(snapshot) +} +``` + +--- + +### 5. Empty paragraph CRDT invariants + +Likely: +- `src/crdt/paragraph.rs` [INFERENCE] +- `src/crdt/doc.rs` [INFERENCE] + +#### Required invariant change + +Empty paragraphs must be: +- stable nodes in Loro tree +- not removed or merged implicitly during normalization + +Add rule: + +```rust +// MUST NOT auto-prune empty paragraphs if they have caret focus or selection anchors +``` + +Add flag: + +```rust +struct Paragraph { + id: ParagraphId, + text: Rope, + keep_alive: bool, // NEW +} +``` + +Set `keep_alive = true` when caret is inside. + +--- + +### 6. Caret affinity rules (core logic change) + +Introduce explicit rules instead of implicit gravity: + +| Situation | Behavior | +|----------|----------| +| empty paragraph + insert text before | stay in same paragraph | +| empty paragraph + delete paragraph above | move via Upstream | +| empty paragraph + insert below | Downstream | +| empty paragraph disappears | resolve via affinity fallback | + +--- + +### 7. Tests + +Likely: +- `tests/editor_caret.rs` [INFERENCE] +- `tests/crdt_projection.rs` [INFERENCE] + +Add tests: + +#### A. Empty paragraph stability + +- create doc: + - paragraph A (text) + - paragraph B (empty) + - paragraph C (text) +- place caret in B +- insert/delete in A and C +- assert caret remains in B + +#### B. Replay test + +- record edit sequence: + - insert text + - create empty paragraph + - move caret into empty paragraph +- replay +- assert identical caret final position + +#### C. Deletion fallback + +- delete empty paragraph +- caret resolves to: + - next paragraph if affinity = Downstream + - previous if Upstream + +#### D. Projection invariant + +- empty paragraph must always produce visible node if caret exists + +--- + +## Sequence + +1. Extend caret model (add empty paragraph anchor + affinity) +2. Update remap logic (boundary-aware handling) +3. Update CRDT projection (preserve empty nodes) +4. Fix lifecycle restore (two-phase resolution) +5. Add keep-alive invariant in paragraph CRDT +6. Implement tests +7. Validate replay consistency + +--- + +## Edge Cases + +- Empty paragraph deleted while caret is inside → must not produce dangling paragraph id +- Concurrent edits remove and reinsert same paragraph id +- Rapid insert/delete around empty paragraph boundary +- Undo/redo restoring stale caret snapshot +- Multi-selection spanning empty paragraph + text paragraph +- Reflow where empty paragraph becomes merged or normalized away + +--- + +## Verification + +- Run caret placement tests across: + - empty paragraph focus + - boundary transitions + - replay consistency +- Validate CRDT projection does not drop empty paragraphs with active caret +- Ensure remap is idempotent: + - remap(remap(x)) == remap(x) +- Confirm no fallback into adjacent paragraph unless explicit affinity resolution triggers + +--- + +## Critical Files (inferred) + +- `src/editor/selection.rs` +- `src/editor/caret.rs` +- `src/editor/remap.rs` +- `src/editor/lifecycle.rs` +- `src/editor/history.rs` +- `src/crdt/projection.rs` +- `src/crdt/paragraph.rs` +- `src/crdt/doc.rs` + +--- + +If you want, I can next break this into a minimal patch order (smallest safe diffs first) or map it onto the other options so you can compare tradeoffs. + +## Option 1 + +## Option 1 — Preserve empty-boundary metadata instead of pruning/replacing it + +This plan assumes Flowstate uses a CRDT-backed document model (Loro-like, based on your scouts) where “empty paragraph boundary metadata” is currently being normalized away during edits, import, or caret-driven repair. + +The bug context: empty paragraph positions (especially caret parked on a blank line before later content) lose structural identity because a repair/prune step collapses or replaces them. Option 1 explicitly keeps them as first-class paragraph nodes with stable metadata. + +--- + +# Summary + +Change the runtime so empty paragraphs are never treated as “invalid / transient”. Instead, they remain stable CRDT paragraph nodes with preserved boundary metadata (id, position anchor, caret affinity hooks). Modify pruning, edit-command reconciliation, and initialization repair so they *skip removal* of empty-but-valid paragraph nodes and instead normalize only truly orphaned or duplicate structures. + +--- + +# Changes + +## 1. Core CRDT paragraph representation + +### File (likely) +`crates/document/src/paragraph.rs` or `src/crdt/paragraph.rs` + +### Changes +- Ensure paragraph node struct distinguishes: + - `content: Vec` (may be empty) + - `boundary: ParagraphBoundaryMeta` (must persist even if content empty) + +Add invariant: + +```rust +/// Empty content is valid. Never implies deletion. +content.is_empty() == true => paragraph still exists +``` + +Add/strengthen metadata: + +```rust +struct ParagraphBoundaryMeta { + id: ParagraphId, + created_at_op: OpId, + caret_affinity_anchor: Option, + is_explicit_empty_line: bool, +} +``` + +Key change: +- Remove any implicit rule like “empty paragraph == removable tombstone” + +--- + +## 2. Runtime repair / normalization layer + +### File +`crates/runtime/src/paragraph_repair.rs` or similar (EmptyParagraphScout likely pointed here) + +### Current behavior (assumed [INFERENCE]) +- merges consecutive empty paragraphs +- prunes empty paragraphs during normalization +- replaces empty paragraph with newline/text node + +### Option 1 modification + +Replace pruning rule: + +```rust +if paragraph.is_empty() { + drop_or_merge(paragraph) +} +``` + +with: + +```rust +if paragraph.is_empty() { + preserve_boundary(paragraph); + keep_as_stable_node(); +} +``` + +### Add rule set: + +#### Rule A — Empty paragraph preservation +- Never delete empty paragraph nodes if: + - they were explicitly created by user action (Enter, caret split) + - or they have caret affinity anchor + - or they exist in committed CRDT history + +#### Rule B — Duplicate empty collapse (safe only) +Only collapse if ALL are true: +- consecutive empty paragraphs +- no caret anchors +- no op-id divergence requirement (same insertion source) + +Result: +- preserve one node, not zero nodes + +--- + +## 3. Edit command reconciliation + +### File +`crates/editor/src/edit_command.rs` + +### Problem +Edit commands likely treat empty paragraphs as “no-op targets” and collapse them. + +### Change +In insertion / deletion reconciliation: + +- DO NOT interpret empty paragraph as deletion candidate +- If cursor is inside empty paragraph: + - treat as valid insertion target + - preserve paragraph id + +Pseudo-change: + +```rust +match paragraph { + Empty => insert_into_existing_paragraph, + NonEmpty => normal_flow, +} +``` + +becomes: + +```rust +match paragraph { + Empty { stable: true } => insert_into_existing_paragraph, + Empty { stable: false } => only then allow merge/prune, + NonEmpty => normal_flow, +} +``` + +--- + +## 4. Initialization invariant repair + +### File +`crates/doc/src/init.rs` or `document_init.rs` + +### Problem +New documents likely run a cleanup pass that removes empty trailing/leading paragraphs. + +### Change + +Remove or restrict: + +- “trim empty leading/trailing paragraphs” +- “normalize blank structure on load” + +Replace with: + +- ensure at least one paragraph exists +- preserve all explicit empty paragraphs + +Add invariant: + +```rust +fn validate_document(doc: &Document) { + assert!(doc.paragraphs.len() >= 1); + assert!(doc.all_explicit_empty_paragraphs_have_ids()); +} +``` + +Empty paragraphs are valid structural nodes, not formatting artifacts. + +--- + +## 5. Caret affinity system (critical coupling) + +### File +`crates/editor/src/caret_affinity.rs` + +### Change + +Empty paragraph must retain caret binding: + +- caret position = valid even if no inline content +- do not “snap forward” into next paragraph unless explicit user action + +Add rule: + +```rust +if paragraph.is_empty() { + caret.must_bind_to_paragraph_id(); +} +``` + +Remove any logic like: +- “if empty → move caret to next non-empty paragraph” + +--- + +## 6. Duplicate / stale pruning rules + +### File +`crates/runtime/src/prune.rs` + +### Replace global rule: + +OLD: +- prune empty paragraphs aggressively +- treat empty nodes as structural noise + +NEW: + +### Tiered pruning model + +#### Tier 1 — Safe prune +Only prune if: +- paragraph is empty +- AND has no op-id in history +- AND no caret anchor +- AND not referenced by selection snapshot + +#### Tier 2 — Merge-only +- consecutive empty paragraphs: + - merge metadata only + - preserve first node + +#### Tier 3 — Never prune +- user-created empty lines +- caret-anchored paragraphs +- imported structure nodes + +--- + +## 7. New invariants + +Add to document invariants module: + +### Invariants + +1. Empty paragraph ≠ invalid paragraph +2. Every user-created newline produces a stable paragraph node +3. Empty paragraph must preserve: + - id + - op history linkage + - caret affinity (if any) +4. Pruning may reduce duplication, never eliminate structural empties entirely + +--- + +## Sequence + +1. Update paragraph data model (boundary metadata preservation) +2. Adjust runtime repair to stop deletion of empty nodes +3. Fix prune rules (tiered model) +4. Fix edit-command handling for empty targets +5. Fix caret affinity behavior +6. Adjust initialization cleanup pass +7. Add invariants + regression tests + +--- + +## Edge Cases + +### 1. Multiple empty lines in a row +- Must collapse duplicates ONLY if no caret anchors +- Must preserve at least one stable node + +### 2. Empty paragraph at document end +- must not be trimmed on load + +### 3. Caret on empty line + typing +- insertion must not recreate paragraph (avoid duplication) +- must reuse existing node + +### 4. Undo/redo +- empty paragraph creation/deletion must be replayable +- no synthetic reconstruction + +### 5. Import (DOCX or plain text) +- blank lines become real nodes, not filtered artifacts + +--- + +## Verification + +### Unit tests + +Add tests in: +`crates/runtime/tests/empty_paragraph.rs` + +#### Test 1 — empty paragraph persistence +- create doc with empty line +- run repair +- assert paragraph count unchanged + +#### Test 2 — caret binding +- place caret in empty paragraph +- insert character +- assert same paragraph id used + +#### Test 3 — prune safety +- consecutive empty paragraphs +- run prune +- assert at least one remains + +#### Test 4 — initialization +- new document +- assert empty paragraph exists and is stable + +--- + +## Critical Files + +Likely high-signal files to inspect before implementation: + +- `crates/runtime/src/paragraph_repair.rs` +- `crates/runtime/src/prune.rs` +- `crates/editor/src/edit_command.rs` +- `crates/editor/src/caret_affinity.rs` +- `crates/doc/src/init.rs` +- `crates/document/src/paragraph.rs` +- any CRDT layer: `loro`, `oplog`, or `doc_state.rs` + +--- + +If you want, Option 2 can be contrasted directly (treat empties as structural placeholders that are *logically* preserved but physically merged). diff --git a/flowstate.log.2026-06-16 b/flowstate.log.2026-06-16 new file mode 100644 index 00000000..b2481769 --- /dev/null +++ b/flowstate.log.2026-06-16 @@ -0,0 +1,5742 @@ +2026-06-16T07:10:35.933479Z INFO main ThreadId(01) flowstate::workspace::workspace: crates/flowstate/src/workspace/workspace/collab.rs:224: workspace joining collaboration session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:10:35.933555Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:126: joining collaboration session from ticket session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a title=Impact---Defense---Michigan (1).db8 +2026-06-16T07:10:35.933579Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:326: starting collaboration runtime for manager +2026-06-16T07:10:35.933675Z INFO main ThreadId(01) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:55: starting collaboration network runtime +2026-06-16T07:10:35.933729Z DEBUG main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:22: starting collaboration network event pump +2026-06-16T07:10:35.933779Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:225: created joining collaboration session state session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce title=Impact---Defense---Michigan (1).db8 bootstrap_count=1 +2026-06-16T07:10:35.933831Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:308: registered collaboration session id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce open_sessions=1 +2026-06-16T07:10:35.933873Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:168: queued collaboration join-session command session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:10:35.933918Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:71: collaboration network thread started +2026-06-16T07:10:35.934443Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:100: binding collaboration endpoint +2026-06-16T07:10:35.954531Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:106: collaboration endpoint bound peer=81bcdbdfc52cf30985f63721d08d7a720613c0497c3f1f4c3d4940e0f06ab4b4 +2026-06-16T07:10:35.954559Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:228: installed collaboration direct client endpoint installed=true +2026-06-16T07:10:35.954786Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:115: collaboration direct and gossip protocols installed peer=81bcdbdfc52cf30985f63721d08d7a720613c0497c3f1f4c3d4940e0f06ab4b4 +2026-06-16T07:10:35.954836Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="ensure_up" +2026-06-16T07:10:35.954840Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:124: collaboration endpoint ensure-up requested +2026-06-16T07:10:35.954853Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="join_session" +2026-06-16T07:10:35.954853Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:282: joining collaboration network session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:10:35.954856Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:67: attached collaboration direct session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inserted=true attached_sessions=1 +2026-06-16T07:10:35.954858Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:39: spawning collaboration gossip swarm session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:10:35.954859Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:219: registering collaboration bootstrap addresses bootstrap_count=1 +2026-06-16T07:10:35.954872Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:289: collaboration network join subscribed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:10:35.955501Z INFO tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:80: subscribing to collaboration gossip topic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:10:35.955511Z INFO tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:85: subscribed to collaboration gossip topic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:10:35.958328Z INFO main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:41: collaboration endpoint status changed online=true +2026-06-16T07:10:35.958344Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:770: collaboration endpoint online state applied to session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce online=true previous_online=true +2026-06-16T07:10:35.958362Z INFO main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:41: collaboration endpoint status changed online=true +2026-06-16T07:10:35.958364Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:770: collaboration endpoint online state applied to session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce online=true previous_online=true +2026-06-16T07:10:50.935450Z WARN main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:602: detaching collaboration session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce reason=JoinFailed("Couldn't reach anyone in this session. Make sure the inviter is online and the invite is current, then try again.") phase=Joining(Subscribing) +2026-06-16T07:10:50.935485Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:190: cannot flush remote collaboration patches because editor is missing session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 +2026-06-16T07:10:50.935495Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:651: collaboration session detached and cleaned up session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:10:50.935541Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="leave_session" +2026-06-16T07:10:50.935553Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:150: leaving collaboration network session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:10:50.935561Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:67: stopping collaboration gossip swarm session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:10:50.935581Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:75: detached collaboration direct session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce removed=true blob_count=0 handler_removed=false attached_sessions=0 +2026-06-16T07:10:50.935608Z DEBUG tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:94: collaboration gossip swarm stop received session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:10:50.935624Z INFO tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:110: collaboration gossip swarm stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:10:50.935673Z ERROR main ThreadId(01) flowstate::workspace::workspace: crates/flowstate/src/workspace/workspace/collab.rs:270: joining collaboration session failed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce error=Couldn't reach anyone in this session. Make sure the inviter is online and the invite is current, then try again. +2026-06-16T07:13:34.981429Z INFO main ThreadId(01) flowstate::workspace::workspace: crates/flowstate/src/workspace/workspace/collab.rs:224: workspace joining collaboration session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:34.981513Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:126: joining collaboration session from ticket session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a title=Impact---Defense---Michigan (1).db8 +2026-06-16T07:13:34.981543Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:326: starting collaboration runtime for manager +2026-06-16T07:13:34.981668Z INFO main ThreadId(01) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:55: starting collaboration network runtime +2026-06-16T07:13:34.981731Z DEBUG main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:22: starting collaboration network event pump +2026-06-16T07:13:34.981813Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:225: created joining collaboration session state session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce title=Impact---Defense---Michigan (1).db8 bootstrap_count=1 +2026-06-16T07:13:34.981791Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:71: collaboration network thread started +2026-06-16T07:13:34.981884Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:308: registered collaboration session id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce open_sessions=1 +2026-06-16T07:13:34.981938Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:168: queued collaboration join-session command session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:34.982455Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:100: binding collaboration endpoint +2026-06-16T07:13:34.998909Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:106: collaboration endpoint bound peer=021aff0f1ac7856d5f21aa15cb6eddea7e2cae158cf152bc54db3642dc5493af +2026-06-16T07:13:34.998936Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:228: installed collaboration direct client endpoint installed=true +2026-06-16T07:13:34.999160Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:115: collaboration direct and gossip protocols installed peer=021aff0f1ac7856d5f21aa15cb6eddea7e2cae158cf152bc54db3642dc5493af +2026-06-16T07:13:34.999259Z INFO main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:41: collaboration endpoint status changed online=true +2026-06-16T07:13:34.999309Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="ensure_up" +2026-06-16T07:13:34.999320Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:124: collaboration endpoint ensure-up requested +2026-06-16T07:13:34.999328Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:770: collaboration endpoint online state applied to session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce online=true previous_online=true +2026-06-16T07:13:34.999349Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="join_session" +2026-06-16T07:13:34.999351Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:282: joining collaboration network session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:13:34.999356Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:67: attached collaboration direct session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inserted=true attached_sessions=1 +2026-06-16T07:13:34.999359Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:39: spawning collaboration gossip swarm session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:13:34.999361Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:219: registering collaboration bootstrap addresses bootstrap_count=1 +2026-06-16T07:13:34.999396Z INFO main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:41: collaboration endpoint status changed online=true +2026-06-16T07:13:34.999401Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:770: collaboration endpoint online state applied to session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce online=true previous_online=true +2026-06-16T07:13:34.999423Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:289: collaboration network join subscribed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:13:34.999677Z INFO tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:80: subscribing to collaboration gossip topic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bootstrap_count=1 +2026-06-16T07:13:34.999691Z INFO tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:85: subscribed to collaboration gossip topic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.439037Z INFO tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:200: collaboration gossip neighbor up session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:35.439073Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=27 frame_bytes=63 +2026-06-16T07:13:35.439748Z DEBUG main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:56: routing collaboration neighbor-up event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:35.439843Z DEBUG main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:68: collaboration anti-entropy neighbor-up recovery triggered session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.439850Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:64: collaboration anti-entropy digest scheduled immediately session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.439883Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:184: skipping collaboration digest publish because Loro doc is missing session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.439938Z TRACE main ThreadId(01) flowstate::collab::session::asset_transfer: crates/flowstate/src/collab/asset_transfer.rs:21: skipping collaboration asset scan because editor is missing session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce preferred_peer=Some(PublicKey(60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a)) +2026-06-16T07:13:35.439998Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:743: collaboration neighbor up session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a inserted=true neighbors=1 +2026-06-16T07:13:35.440014Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=27 +2026-06-16T07:13:35.440021Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=27 +2026-06-16T07:13:35.440052Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.440059Z DEBUG main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:102: ignored collaboration digest because Loro doc is missing session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=27 +2026-06-16T07:13:35.440090Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:426: requesting collaboration join snapshot session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce inviter=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a candidate_count=1 +2026-06-16T07:13:35.440121Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="pull_snapshot" +2026-06-16T07:13:35.440135Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:190: pulling collaboration snapshot session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce candidate_count=1 +2026-06-16T07:13:35.440143Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:275: starting collaboration direct pull session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" candidate_count=1 per_peer_timeout=10s +2026-06-16T07:13:35.440152Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:279: attempting collaboration direct pull peer session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:35.440192Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:303: dialing collaboration direct peer session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:35.448652Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:308: opening collaboration direct stream session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a +2026-06-16T07:13:35.448676Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:314: sending collaboration direct request frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a frame_bytes=38 +2026-06-16T07:13:35.448681Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:414: writing collaboration direct frame frame_bytes=38 +2026-06-16T07:13:35.568776Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:319: received collaboration direct response header session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a response="ok" +2026-06-16T07:13:35.568799Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:429: reading collaboration direct payload payload_bytes=5848553 chunk_bytes=262144 +2026-06-16T07:13:35.853389Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:323: read collaboration direct response payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a bytes=5848553 +2026-06-16T07:13:35.853412Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:282: collaboration direct pull peer succeeded session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce request_kind="snapshot" peer=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a bytes=5848553 +2026-06-16T07:13:35.853416Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:307: collaboration direct pull succeeded session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce kind="snapshot" candidate_count=1 bytes=5848553 +2026-06-16T07:13:35.856322Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:373: collaboration join snapshot pulled session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce snapshot_bytes=5848553 +2026-06-16T07:13:35.856329Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:559: building collaboration document from join snapshot session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce snapshot_bytes=5848553 +2026-06-16T07:13:35.933351Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 frame_bytes=148 +2026-06-16T07:13:35.982531Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:579: built collaboration document from join snapshot session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce paragraphs=5259 blocks=5259 assets=0 +2026-06-16T07:13:35.983687Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:13:35.983693Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:13:35.983696Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.983697Z DEBUG main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:25: ignored collaboration presence update before local presence was established session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=143 +2026-06-16T07:13:35.983713Z INFO main ThreadId(01) flowstate::workspace::workspace: crates/flowstate/src/workspace/workspace/collab.rs:250: collaboration join completed; opening joined document session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce title=Impact---Defense---Michigan (1).db8 (shared) +2026-06-16T07:13:35.993123Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:183: attaching joined collaboration session to editor session_id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce panel_id=84e8e2ce-bf18-4ba1-9eaf-22b68dab754e +2026-06-16T07:13:35.993130Z TRACE main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:322: collaboration runtime already available +2026-06-16T07:13:35.993137Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:200: registered collaboration direct handler for joined session session_id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce panel_id=84e8e2ce-bf18-4ba1-9eaf-22b68dab754e +2026-06-16T07:13:35.993138Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:371: requested collaboration endpoint address for joined session session_id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993140Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:488: attaching joined collaboration editor session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce panel_id=84e8e2ce-bf18-4ba1-9eaf-22b68dab754e pending_remote_updates=0 pending_remote_patches=0 +2026-06-16T07:13:35.993142Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:512: attaching collaboration session hooks session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce phase=Joining(Building) +2026-06-16T07:13:35.993148Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:878: collaboration undo manager attached session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce merge_interval_ms=500 max_undo_steps=300 +2026-06-16T07:13:35.993147Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="register_direct_handler" +2026-06-16T07:13:35.993149Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:801: attaching collaboration editor hooks session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993150Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:128: registering collaboration direct handler session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993154Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:82: registered collaboration direct session handler session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce replaced=false handler_count=1 +2026-06-16T07:13:35.993156Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="mint_ticket_addr" +2026-06-16T07:13:35.993173Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:226: minting collaboration ticket address peer=021aff0f1ac7856d5f21aa15cb6eddea7e2cae158cf152bc54db3642dc5493af +2026-06-16T07:13:35.993458Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:805: set collaboration recovery path for untitled document session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993462Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:834: collaboration editor hooks attached session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce subscriptions=2 +2026-06-16T07:13:35.993464Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:847: attaching collaboration Loro local-update publish hook session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993466Z DEBUG main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:77: starting collaboration direct request pump session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993467Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:889: starting collaboration undo request pump session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993469Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:25: starting collaboration timers session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993469Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:105: starting collaboration digest timer session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993470Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:125: starting collaboration presence timer session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993471Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:141: starting collaboration self-check timer session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:35.993595Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:35.994853Z TRACE main ThreadId(01) flowstate::collab::session::asset_transfer: crates/flowstate/src/collab/asset_transfer.rs:36: scanned collaboration image assets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce assets=0 +2026-06-16T07:13:35.994872Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=27 +2026-06-16T07:13:35.994874Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:507: joined collaboration editor attached session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce panel_id=84e8e2ce-bf18-4ba1-9eaf-22b68dab754e +2026-06-16T07:13:35.994878Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:35.994880Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 +2026-06-16T07:13:35.994881Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 +2026-06-16T07:13:35.994900Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 gossip_kind="digest" gossip_payload_bytes=27 frame_bytes=63 neighbors_only=true +2026-06-16T07:13:35.994905Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=63 neighbors_only=true +2026-06-16T07:13:36.028153Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:657: skipping local collaboration edit flush for inactive phase session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce phase=Joining(Building) +2026-06-16T07:13:36.028164Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:36.028179Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:374: collaboration joined session local peer established session_id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peer=021aff0f1ac7856d5f21aa15cb6eddea7e2cae158cf152bc54db3642dc5493af +2026-06-16T07:13:36.028183Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:523: establishing local collaboration peer presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peer=021aff0f1ac7856d5f21aa15cb6eddea7e2cae158cf152bc54db3642dc5493af +2026-06-16T07:13:36.028207Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:36.028218Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=137 +2026-06-16T07:13:36.028224Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:36.028230Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=137 +2026-06-16T07:13:36.028232Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=137 +2026-06-16T07:13:36.028242Z DEBUG main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:95: publishing collaboration presence snapshot session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=137 +2026-06-16T07:13:36.028246Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:117: queued collaboration presence publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=137 +2026-06-16T07:13:36.028249Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=27 +2026-06-16T07:13:36.028252Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=137 gossip_kind="presence" gossip_payload_bytes=137 frame_bytes=142 neighbors_only=false +2026-06-16T07:13:36.028257Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=142 neighbors_only=false +2026-06-16T07:13:36.028262Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:36.028280Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=137 +2026-06-16T07:13:36.028281Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=137 +2026-06-16T07:13:36.028285Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:36.028285Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 +2026-06-16T07:13:36.028287Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 +2026-06-16T07:13:36.028294Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=137 gossip_kind="presence" gossip_payload_bytes=137 frame_bytes=142 neighbors_only=false +2026-06-16T07:13:36.028305Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=142 neighbors_only=false +2026-06-16T07:13:36.028309Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 gossip_kind="digest" gossip_payload_bytes=27 frame_bytes=63 neighbors_only=true +2026-06-16T07:13:36.028315Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=63 neighbors_only=true +2026-06-16T07:13:36.029457Z TRACE main ThreadId(01) flowstate::collab::session::asset_transfer: crates/flowstate/src/collab/asset_transfer.rs:36: scanned collaboration image assets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce assets=0 +2026-06-16T07:13:36.029461Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:555: local collaboration peer established session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peer=021aff0f1ac7856d5f21aa15cb6eddea7e2cae158cf152bc54db3642dc5493af peers_present=0 +2026-06-16T07:13:36.029470Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:169: collaboration digest timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 inbound_since_last_digest=true +2026-06-16T07:13:36.029473Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=27 +2026-06-16T07:13:36.029474Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:216: finished collaboration digest round session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 +2026-06-16T07:13:36.029476Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=0 neighbors=1 +2026-06-16T07:13:36.029478Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:59: collaboration anti-entropy digest sent session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce next_digest_in=9.562s +2026-06-16T07:13:36.029481Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:36.029483Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 +2026-06-16T07:13:36.029484Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 +2026-06-16T07:13:36.029492Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=27 gossip_kind="digest" gossip_payload_bytes=27 frame_bytes=63 neighbors_only=true +2026-06-16T07:13:36.029495Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=63 neighbors_only=true +2026-06-16T07:13:36.032368Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:36.032374Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:36.052549Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:36.052561Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:36.061681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:36.061692Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:37.973034Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:37.973129Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:37.973143Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=true +2026-06-16T07:13:38.023885Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:38.023907Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:38.023918Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:38.023923Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:38.023926Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:38.023944Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:38.023951Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:38.106071Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:38.106094Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:38.356703Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:38.356727Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:38.506456Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:38.506475Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.017541Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.017578Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.018607Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=96 +2026-06-16T07:13:39.018616Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.018623Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=96 +2026-06-16T07:13:39.018625Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=96 +2026-06-16T07:13:39.018645Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.018647Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.018650Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.018658Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=96 +2026-06-16T07:13:39.018661Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=96 gossip_kind="update" gossip_payload_bytes=96 frame_bytes=100 neighbors_only=false +2026-06-16T07:13:39.018665Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=100 neighbors_only=false +2026-06-16T07:13:39.028419Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.028436Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.028493Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.028500Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.028502Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.028504Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.028513Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.028514Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.028516Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.028519Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.028518Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.028521Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.028523Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.053209Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.053240Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.053313Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.053320Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.053323Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.053326Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.053336Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.053337Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.053339Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.053343Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.053346Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.053349Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.063091Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.063107Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.063162Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.063168Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.063170Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.063171Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.063181Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.063182Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.063183Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.063184Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.063185Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.063186Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.063187Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.079065Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.079074Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.098447Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.098466Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.098532Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.098539Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.098542Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.098543Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.098553Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.098555Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.098557Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.098561Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.098564Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.098567Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.108033Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.108047Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.108095Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.108102Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.108103Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.108105Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.108114Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.108115Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.108116Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.108116Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.108119Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.108119Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.108121Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.131533Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.131552Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.131641Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.131650Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.131653Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.131655Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.131664Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.131665Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.131668Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.131675Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.131678Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.131682Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.141318Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.141338Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.141397Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.141406Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.141411Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.141413Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.141419Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.141421Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.141422Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.141425Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.141431Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.141434Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.141437Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.163862Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.163883Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.163953Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.163961Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.163964Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.163967Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.163976Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.163977Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.163979Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.163984Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.163988Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.163992Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.173468Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.173483Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.173531Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.173537Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.173540Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.173541Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.173549Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.173550Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.173551Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.173553Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.173554Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.173557Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.173559Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.196838Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.196856Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.196924Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.196931Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.196934Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.196936Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.196945Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.196946Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.196948Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.196980Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.196984Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.196987Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.206365Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.206380Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.206424Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.206430Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.206432Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.206434Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.206444Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.206445Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.206447Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.206448Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.206449Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.206450Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.206452Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.222394Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:39.222411Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:39.222418Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.222420Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:39.222422Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:39.222440Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:39.222446Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:39.222467Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.222471Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.298885Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.298906Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.298967Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.298973Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.298976Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.298978Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.298989Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.298991Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.298993Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.299009Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.299016Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.299019Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.308639Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.308658Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.308711Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.308718Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.308721Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.308723Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.308732Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.308733Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.308735Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.308737Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.308739Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.308742Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.308744Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.332682Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.332701Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.332759Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.332765Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.332768Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.332769Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.332780Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.332782Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.332783Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.332790Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.332794Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.332797Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.342282Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.342296Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.342338Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.342345Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.342347Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.342349Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.342357Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.342358Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.342359Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.342361Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.342362Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.342364Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.342366Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.364623Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.364640Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.364694Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.364700Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.364702Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.364705Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.364719Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.364723Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.364724Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.364730Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.364733Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.364736Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.374187Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.374204Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.374251Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.374257Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.374261Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.374263Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.374273Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.374274Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.374274Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.374276Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.374277Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.374278Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.374278Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.396422Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.396440Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.396493Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.396502Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.396505Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.396507Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.396516Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.396517Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.396519Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.396526Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.396532Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.396535Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.406248Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.406267Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.406318Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.406324Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.406327Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.406329Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.406340Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.406342Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.406343Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.406343Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.406346Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.406345Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.406348Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.428511Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.428528Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.428580Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.428588Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.428593Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.428594Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.428601Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.428602Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.428604Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.428615Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.428619Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.428623Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.438040Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.438052Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.438091Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.438098Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.438100Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.438101Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.438108Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.438109Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.438111Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.438113Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.438115Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.438118Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.438120Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.472980Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.472996Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.473047Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.473055Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.473059Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.473061Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.473069Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.473070Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.473072Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.473078Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.473080Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.473083Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.482553Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.482568Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.482608Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.482615Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.482617Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.482619Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.482628Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.482630Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.482630Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.482632Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.482632Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.482634Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.482634Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.506012Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.506028Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.506080Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.506087Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.506091Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.506093Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.506101Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.506102Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.506104Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.506109Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.506112Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.506116Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.515585Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.515598Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.515635Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.515643Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.515645Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.515647Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.515653Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.515655Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.515656Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.515658Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.515659Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.515661Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.515663Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.531500Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:39.531516Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:39.531525Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.531528Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:39.531530Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:39.531546Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:39.531550Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:39.531580Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.531584Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.551463Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.551487Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.551569Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.551577Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.551580Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.551582Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.551592Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.551593Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.551596Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.551598Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.551600Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.551604Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.561063Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.561082Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.561136Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.561143Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.561145Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.561150Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.561157Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.561159Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.561161Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.561163Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.561164Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.561165Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.561168Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.584898Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.584917Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.584982Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.584991Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.584996Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.584998Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.585004Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.585006Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.585008Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.585033Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.585037Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.585040Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.594523Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.594537Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.594586Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.594594Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.594596Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.594597Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.594606Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.594607Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.594609Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.594611Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.594614Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.594616Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.594618Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.617018Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.617038Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.617099Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.617107Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.617113Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.617117Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.617125Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.617127Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.617129Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.617139Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.617147Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.617150Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.626621Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.626638Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.626692Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.626698Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.626701Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.626703Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.626715Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.626716Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.626716Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.626718Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.626718Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.626720Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.626721Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.649359Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.649378Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.649436Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.649443Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.649447Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.649449Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.649457Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.649458Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.649460Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.649471Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.649474Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.649478Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.659099Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.659117Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.659167Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.659174Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.659178Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.659181Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.659191Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.659195Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.659195Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.659197Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.659198Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.659200Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.659201Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.681929Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.681952Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.682008Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.682016Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.682020Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.682022Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.682030Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.682031Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.682033Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.682041Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.682043Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.682046Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.691612Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.691632Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.691687Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.691698Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.691707Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.691709Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.691710Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.691709Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.691713Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.691713Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.691738Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.691743Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.691749Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.713897Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.713915Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.713969Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.713977Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.713982Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.713985Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.713991Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.713993Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.713995Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.714007Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.714011Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.714014Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.723391Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.723409Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.723451Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.723459Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.723462Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.723464Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.723470Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.723471Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.723472Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.723475Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.723478Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.723480Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.723486Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.745479Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.745495Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.745551Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.745558Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.745561Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.745563Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.745571Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.745573Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.745574Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.745579Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.745582Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.745585Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.754978Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.754993Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:39.755028Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:39.755035Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.755037Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.755039Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:39.755047Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:39.755049Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.755050Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.755052Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:39.755051Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:39.755054Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.755056Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:39.780352Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.780360Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.792112Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.792119Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.839676Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:39.839697Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:39.839744Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:39.839748Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:39.839750Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:39.839770Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:39.839776Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:39.923052Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.923062Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.932389Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.932398Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.941754Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.941766Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:39.951810Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:39.951823Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:40.314684Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:40.314698Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:40.845816Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:40.845845Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:41.376956Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:41.376983Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:41.908119Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:41.908147Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.251877Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.251907Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.251997Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.252010Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.252019Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.252021Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.252024Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.252025Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.252027Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.252044Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.252047Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.252050Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.261907Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.261931Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.261997Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.262008Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.262012Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.262015Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.262019Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.262021Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.262023Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.262026Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.262033Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.262038Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.262042Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.285940Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.285963Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.286048Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.286056Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.286062Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.286064Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.286072Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.286073Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.286075Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.286084Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.286087Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.286092Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.295707Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.295724Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.295781Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.295803Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.295804Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.295806Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.295809Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.295818Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.295821Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.295823Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.295838Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.295840Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.295843Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.330833Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.330854Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.330943Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.330948Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.330952Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.330956Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.330966Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.330969Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.330971Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.330975Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.330977Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.330981Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.340538Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.340556Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.340608Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.340615Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.340618Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.340620Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.340627Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.340629Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.340631Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.340633Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.340633Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.340635Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.340638Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.364396Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.364414Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.364474Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.364482Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.364484Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.364486Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.364500Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.364500Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.364502Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.364503Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.364504Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.364505Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.374091Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.374107Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.374158Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.374165Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.374168Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.374170Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.374176Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.374178Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.374180Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.374182Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.374182Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.374184Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.374186Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.396480Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.396498Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.396554Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.396562Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.396564Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.396566Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.396575Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.396576Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.396578Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.396582Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.396585Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.396587Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.406211Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.406227Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.406276Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.406283Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.406286Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.406288Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.406298Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.406301Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.406301Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.406303Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.406304Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.406306Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.406306Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.428583Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.428602Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.428657Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.428664Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.428667Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.428670Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.428678Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.428681Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.428683Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.428686Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.428688Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.428691Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.438208Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.438222Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.438266Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.438272Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.438275Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.438276Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.438285Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.438286Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.438287Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.438287Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.438289Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.438290Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.438291Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.460472Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.460493Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.460573Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.460581Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.460585Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.460587Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.460596Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.460597Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.460600Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.460605Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.460608Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.460611Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.470042Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.470057Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.470105Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.470111Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.470114Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.470115Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.470123Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.470125Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.470126Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.470128Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.470128Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.470130Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.470132Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.492510Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.492530Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.492595Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.492602Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.492605Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.492606Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.492616Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.492617Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.492619Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.492626Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.492629Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.492632Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.502083Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.502098Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.502148Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.502149Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.502159Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.502161Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.502170Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.502173Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.502174Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.502176Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.502179Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.502181Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.502183Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.524333Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.524352Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.524411Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.524417Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.524420Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.524421Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.524434Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.524436Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.524437Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.524437Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.524439Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.524440Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.533862Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.533877Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.533925Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=91 +2026-06-16T07:13:42.533933Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.533937Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.533939Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 +2026-06-16T07:13:42.533945Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.533947Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.533949Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.533952Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.533952Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=91 +2026-06-16T07:13:42.533955Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=91 gossip_kind="update" gossip_payload_bytes=91 frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.533958Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=95 neighbors_only=false +2026-06-16T07:13:42.549511Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:42.549526Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:42.549534Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.549537Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:42.549538Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:42.549542Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:42.549544Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.549555Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:42.549558Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:42.568722Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.568746Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.568806Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.568815Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.568818Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.568821Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.568827Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.568828Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.568830Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.568841Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.568845Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.568848Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.578516Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.578533Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.578577Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.578584Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.578587Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.578588Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.578595Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.578596Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.578598Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.578600Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.578602Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.578603Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.578605Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.602116Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.602136Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.602194Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.602202Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.602206Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.602207Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.602217Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.602219Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.602221Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.602224Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.602227Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.602230Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.611648Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.611660Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.611702Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.611709Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.611711Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.611713Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.611720Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.611722Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.611723Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.611725Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.611725Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.611727Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.611729Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.634662Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.634680Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.634733Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.634742Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.634744Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.634746Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.634755Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.634757Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.634759Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.634765Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.634768Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.634771Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.644318Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.644332Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.644373Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.644379Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.644381Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.644382Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.644392Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.644393Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.644399Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.644400Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.644403Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.644403Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.644405Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.666711Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.666728Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.666779Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.666787Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.666792Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.666795Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.666800Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.666801Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.666802Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.666812Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.666817Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.666821Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.676223Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.676237Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.676279Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.676286Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.676288Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.676290Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.676299Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.676301Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.676302Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.676305Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.676305Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.676307Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.676309Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.692218Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:42.692228Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.743187Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:42.743209Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:42.743220Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.743224Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:42.743226Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:42.743245Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:42.743249Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:42.757353Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.757379Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.757457Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.757464Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.757467Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.757469Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.757481Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.757484Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.757483Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.757486Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.757486Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.757488Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.767290Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.767329Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.767395Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.767402Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.767406Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.767408Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.767418Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.767420Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.767421Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.767423Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.767425Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.767425Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.767427Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.791444Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.791466Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.791531Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.791538Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.791540Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.791542Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.791552Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.791555Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.791556Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.791556Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.791558Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.791561Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.801103Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.801122Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.801171Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.801178Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.801180Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.801182Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.801191Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.801192Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.801193Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.801193Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.801195Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.801196Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.801197Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.823656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.823675Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.823734Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.823741Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.823747Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.823748Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.823757Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.823758Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.823760Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.823771Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.823774Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.823777Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.833468Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.833484Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.833534Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.833541Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.833544Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.833546Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.833556Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.833557Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.833559Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.833561Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.833564Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.833567Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.833570Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.856036Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.856055Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.856115Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.856124Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.856129Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.856132Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.856136Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.856137Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.856139Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.856171Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.856180Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.856184Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.865826Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.865843Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.865896Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.865904Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.865910Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.865912Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.865918Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.865919Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.865921Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.865924Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.865932Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.865937Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.865940Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.887678Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.887697Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.887755Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.887763Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.887767Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.887769Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.887777Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.887778Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.887779Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.887791Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.887794Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.887798Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.897295Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.897310Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.897354Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.897362Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.897364Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.897367Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.897374Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.897375Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.897377Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.897379Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.897379Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.897382Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.897384Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.919187Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.919205Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.919257Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.919265Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.919271Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.919272Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.919279Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.919280Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.919282Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.919290Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.919294Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.919297Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.928784Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.928799Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.928841Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.928848Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.928850Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.928852Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.928860Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.928861Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.928863Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.928863Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.928865Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.928866Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.928868Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.950463Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.950485Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.950565Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.950574Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.950579Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.950582Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.950590Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.950591Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.950594Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.950605Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.950610Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.950613Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.959908Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.959922Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.959976Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.959983Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.959985Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.959987Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.959995Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.959996Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.959998Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.959998Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.960000Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.960000Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.960002Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.994777Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.994800Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:42.994872Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:42.994879Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:42.994882Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.994884Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:42.994894Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:42.994896Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:42.994898Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:42.994902Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:42.994924Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:42.994926Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.004389Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.004403Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.004455Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.004461Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.004464Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.004466Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.004473Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.004474Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.004476Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.004478Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.004478Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.004480Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.004483Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.042868Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.042888Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.042951Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.042960Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.042964Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.042967Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.042973Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.042974Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.042975Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.042988Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.042993Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.042997Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.052525Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.052552Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.052628Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.052637Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.052641Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.052643Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.052660Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.052663Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.052664Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.052668Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.052667Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.052671Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.052675Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.075541Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.075563Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.075643Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.075650Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.075653Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.075655Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.075666Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.075670Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.075671Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.075672Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.075673Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.075675Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.085191Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.085210Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.085274Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.085282Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.085286Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.085288Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.085296Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.085298Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.085300Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.085303Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.085303Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.085305Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.085308Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.106875Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.106897Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.106963Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.106970Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.106974Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.106976Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.106985Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.106986Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.106988Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.106991Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.106993Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.107000Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.116460Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.116475Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.116525Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.116532Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.116534Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.116537Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.116545Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.116547Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.116548Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.116550Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.116550Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.116552Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.116554Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.137909Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.137929Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.137986Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.137993Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.137997Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.137999Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.138008Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.138010Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.138012Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.138017Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.138019Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.138022Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.147428Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.147443Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.147485Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.147491Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.147493Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.147494Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.147503Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.147504Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.147505Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.147505Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.147507Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.147508Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.147511Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.169024Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.169043Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.169099Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.169104Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.169106Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.169109Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.169121Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.169121Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.169122Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.169124Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.169123Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.169126Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.178587Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.178603Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.178644Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.178651Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.178653Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.178654Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.178664Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.178664Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.178665Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.178666Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.178666Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.178668Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.178668Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.200083Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.200100Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.200153Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.200159Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.200162Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.200164Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.200174Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.200175Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.200176Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.200180Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.200182Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.200185Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.209627Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.209640Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:13:43.209680Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:13:43.209686Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.209687Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.209689Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:13:43.209698Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:13:43.209699Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.209700Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.209702Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.209703Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:13:43.209705Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.209707Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:13:43.221904Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:43.221915Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:43.221924Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:43.221929Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:43.221931Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:43.221947Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:43.221952Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:43.224771Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.224778Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.224831Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.224834Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.364713Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.364724Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.374087Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.374096Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.383425Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.383437Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.393891Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.393899Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:43.645718Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 +2026-06-16T07:13:43.648037Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:13:43.648052Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:13:43.648058Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:43.648068Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:90: collaboration anti-entropy digest considered session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce relation=Equal our_vv_bytes=38 +2026-06-16T07:13:43.648073Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:115: collaboration anti-entropy digest needs no pull session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a relation=Equal +2026-06-16T07:13:43.648076Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:122: handled collaboration digest session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 relation=Equal action="none" +2026-06-16T07:13:43.756573Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:43.756594Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:44.286846Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:44.286876Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:44.817981Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:44.818009Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:45.349125Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:45.349152Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:45.592614Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:169: collaboration digest timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 inbound_since_last_digest=true +2026-06-16T07:13:45.592656Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 +2026-06-16T07:13:45.592665Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:216: finished collaboration digest round session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 +2026-06-16T07:13:45.592682Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=0 neighbors=1 +2026-06-16T07:13:45.592688Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:59: collaboration anti-entropy digest sent session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce next_digest_in=8.848s +2026-06-16T07:13:45.593150Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:45.593164Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:13:45.593172Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:13:45.593222Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 neighbors_only=true +2026-06-16T07:13:45.593236Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=74 neighbors_only=true +2026-06-16T07:13:45.880268Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:45.880291Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:45.936391Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 frame_bytes=148 +2026-06-16T07:13:45.936432Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:13:45.936442Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:13:45.936448Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:45.936452Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:13: applying remote collaboration presence update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=143 +2026-06-16T07:13:45.936509Z INFO main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:128: collaboration peer presence count changed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce previous=0 peers_present=1 +2026-06-16T07:13:45.936520Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:13:45.936577Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:13:45.936648Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:45.936656Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:46.031320Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:183: collaboration presence timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:46.031344Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:46.031356Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:46.031363Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:13:46.031364Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:46.031369Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:46.031371Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:46.031374Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:13:46.031393Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:46.031399Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:46.411411Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:46.411439Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:46.942545Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:46.942573Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:47.473689Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:47.473717Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:48.004833Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:48.004863Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:48.535984Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:48.536013Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:49.067122Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:49.067149Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:49.598279Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:49.598298Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:50.129403Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:50.129429Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:50.660545Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:50.660577Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:51.191102Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:51.191115Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:51.722330Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:51.722359Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:52.253439Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:52.253457Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:52.783898Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:52.783926Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:53.247244Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 +2026-06-16T07:13:53.247304Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:13:53.247319Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:13:53.247326Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:53.247337Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:90: collaboration anti-entropy digest considered session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce relation=Equal our_vv_bytes=38 +2026-06-16T07:13:53.247345Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:115: collaboration anti-entropy digest needs no pull session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a relation=Equal +2026-06-16T07:13:53.247349Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:122: handled collaboration digest session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 relation=Equal action="none" +2026-06-16T07:13:53.315043Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:53.315069Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:53.846187Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:53.846214Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:54.377326Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:54.377353Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:54.441026Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:169: collaboration digest timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 inbound_since_last_digest=true +2026-06-16T07:13:54.441053Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 +2026-06-16T07:13:54.441058Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:216: finished collaboration digest round session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 +2026-06-16T07:13:54.441066Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:54.441074Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:13:54.441075Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:13:54.441079Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:13:54.441078Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:59: collaboration anti-entropy digest sent session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce next_digest_in=8.003s +2026-06-16T07:13:54.441111Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 neighbors_only=true +2026-06-16T07:13:54.441121Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=74 neighbors_only=true +2026-06-16T07:13:54.907594Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:54.907623Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:55.438267Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:55.438294Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:55.934577Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 frame_bytes=148 +2026-06-16T07:13:55.934649Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:13:55.934667Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:13:55.934678Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:55.934684Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:13: applying remote collaboration presence update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=143 +2026-06-16T07:13:55.934726Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:13:55.934758Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:13:55.969362Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:55.969376Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:56.032491Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:183: collaboration presence timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:13:56.032531Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:13:56.032546Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:13:56.032556Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:13:56.032561Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:13:56.032564Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:56.032568Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:13:56.032577Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:13:56.032595Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:13:56.032603Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:13:56.500480Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:56.500509Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:57.031317Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:57.031348Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:57.562481Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:57.562509Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:58.093097Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:58.093123Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:58.624197Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:58.624209Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:59.155320Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:59.155348Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:13:59.686460Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:13:59.686489Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:00.217606Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:00.217633Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:00.748750Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:00.748778Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:01.279893Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:01.279920Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:01.811028Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:01.811056Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:02.342181Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:02.342209Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:02.348622Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 +2026-06-16T07:14:02.362764Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:14:02.362778Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:14:02.362781Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:02.362786Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:90: collaboration anti-entropy digest considered session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce relation=Equal our_vv_bytes=38 +2026-06-16T07:14:02.362788Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:115: collaboration anti-entropy digest needs no pull session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a relation=Equal +2026-06-16T07:14:02.362790Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:122: handled collaboration digest session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 relation=Equal action="none" +2026-06-16T07:14:02.444309Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:169: collaboration digest timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 inbound_since_last_digest=true +2026-06-16T07:14:02.444350Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 +2026-06-16T07:14:02.444359Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:216: finished collaboration digest round session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 +2026-06-16T07:14:02.444382Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:02.444389Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:59: collaboration anti-entropy digest sent session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce next_digest_in=11.89s +2026-06-16T07:14:02.444423Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:02.444434Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:14:02.444443Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:14:02.444483Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 neighbors_only=true +2026-06-16T07:14:02.444497Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=74 neighbors_only=true +2026-06-16T07:14:02.873322Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:02.873350Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:03.404456Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:03.404479Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:03.935555Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:03.935573Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:04.466676Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:04.466704Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:04.996948Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:04.996976Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:05.528083Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:05.528111Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:06.030774Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:202: running collaboration self-check tick session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce idle_for=22.820897042s +2026-06-16T07:14:06.092975Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:275: collaboration projection self-check passed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce live_hash=52b5abfdda90cbef +2026-06-16T07:14:06.093093Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:183: collaboration presence timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:06.093110Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:06.093121Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:14:06.093126Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:06.093128Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:06.093133Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:06.093135Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:06.093135Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:06.093152Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:06.093156Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:06.093163Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:14:06.093167Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:14:06.147623Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 frame_bytes=148 +2026-06-16T07:14:06.147646Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:14:06.147651Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:14:06.147653Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:06.147655Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:13: applying remote collaboration presence update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=143 +2026-06-16T07:14:06.147668Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:06.147678Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:06.624236Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:06.624255Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:07.155361Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:07.155388Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:07.686500Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:07.686528Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.099190Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.099219Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.099302Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.099312Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.099317Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.099320Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.099328Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.099329Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.099331Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.099346Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.099351Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.099355Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.108947Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.108965Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.109022Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.109029Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.109033Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.109035Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.109045Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.109046Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.109048Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.109051Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.109052Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.109054Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.109057Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.135431Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.135441Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.144484Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.144494Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.177552Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:08.177571Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:14:08.177588Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.177592Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:08.177594Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:08.177645Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:14:08.177651Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:14:08.218988Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.219001Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.271347Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.271360Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.282289Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.282303Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.291049Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.291058Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.300560Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.300572Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.428329Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.428358Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.428434Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.428442Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.428446Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.428449Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.428459Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.428461Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.428463Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.428470Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.428473Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.428477Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.437972Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.437992Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.438060Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.438068Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.438071Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.438074Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.438081Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.438082Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.438084Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.438087Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.438103Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.438108Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.438112Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.461429Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.461448Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.461515Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.461523Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.461527Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.461529Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.461537Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.461538Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.461540Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.461545Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.461548Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.461551Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.471179Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.471196Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.471253Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.471260Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.471263Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.471265Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.471273Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.471274Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.471276Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.471279Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.471278Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.471281Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.471283Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.493266Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.493285Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.493346Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.493354Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.493358Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.493361Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.493367Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.493368Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.493370Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.493381Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.493384Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.493387Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.503186Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.503202Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.503255Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.503259Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.503261Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.503263Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.503277Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.503279Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.503280Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.503283Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.503280Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.503310Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.503322Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.525175Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.525193Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.525249Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.525253Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.525256Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.525257Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.525270Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.525271Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.525273Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.525273Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.525276Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.525279Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.534930Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.534948Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.534996Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.535002Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.535005Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.535007Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.535016Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.535017Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.535018Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.535020Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.535022Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.535024Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.535027Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.556681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.556701Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.556760Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.556763Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.556767Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.556768Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.556783Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.556784Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.556786Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.556786Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.556788Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.556791Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.566317Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.566332Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.566380Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.566387Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.566389Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.566391Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.566400Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.566401Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.566403Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.566405Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.566430Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.566435Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.566438Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.588227Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.588248Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.588303Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.588307Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.588309Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.588311Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.588325Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.588326Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.588328Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.588328Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.588332Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.588335Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.597968Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.597985Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.598036Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.598040Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.598042Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.598043Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.598058Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.598059Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.598059Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.598060Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.598061Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.598063Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.598063Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.613312Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.613323Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.669122Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:08.669143Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:14:08.669166Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.669183Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:08.669188Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:08.669210Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:14:08.669220Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:14:08.686530Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.686551Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.686613Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.686621Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.686624Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.686627Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.686636Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.686639Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.686641Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.686646Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.686648Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.686652Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.696117Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.696154Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.696229Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.696237Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.696240Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.696242Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.696251Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.696252Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.696253Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.696255Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.696256Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.696257Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.696259Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.719530Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.719549Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.719603Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.719610Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.719613Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.719615Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.719624Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.719625Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.719627Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.719633Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.719636Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.719638Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.729082Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.729097Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.729143Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.729149Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.729152Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.729154Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.729161Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.729162Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.729164Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.729166Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.729168Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.729170Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.729172Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.750697Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.750714Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.750769Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.750776Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.750781Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.750783Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.750791Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.750792Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.750793Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.750804Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.750807Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.750810Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.760203Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.760225Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.760299Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.760307Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.760310Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.760312Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.760322Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.760323Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.760325Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.760328Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.760331Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.760334Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.760336Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.782076Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.782098Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.782168Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.782176Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.782179Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.782181Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.782192Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.782195Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.782196Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.782199Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.782202Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.782204Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.791700Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.791718Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.791765Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.791771Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.791773Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.791775Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.791785Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.791786Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.791786Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.791787Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.791788Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.791789Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.791791Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.813353Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.813374Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.813433Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.813441Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.813444Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.813445Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.813455Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.813456Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.813458Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.813464Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.813467Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.813470Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.822925Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.822940Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.822984Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.822991Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.822994Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.822996Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.823002Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.823003Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.823005Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.823007Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.823013Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.823018Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.823020Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.838135Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.838148Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.860066Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.860075Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.860106Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:08.860110Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.886419Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:08.886434Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=115 +2026-06-16T07:14:08.886443Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.886447Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:08.886449Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 +2026-06-16T07:14:08.886465Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=115 gossip_kind="presence" gossip_payload_bytes=115 frame_bytes=119 neighbors_only=false +2026-06-16T07:14:08.886470Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=119 neighbors_only=false +2026-06-16T07:14:08.920188Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.920209Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.920271Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.920278Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.920281Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.920282Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.920293Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.920295Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.920300Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.920303Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.920308Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.920311Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.929858Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.929882Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.929943Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.929948Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.929951Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.929954Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.929968Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.929969Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.929971Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.929973Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.929974Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.929975Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.929977Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.953311Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.953330Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.953388Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.953395Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.953400Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.953402Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.953409Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.953410Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.953412Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.953420Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.953424Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.953426Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.962931Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.962947Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.962989Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.962996Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.962999Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.963001Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.963008Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.963010Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.963011Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.963013Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.963017Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.963020Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.963022Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.984767Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.984783Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.984839Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.984846Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.984849Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.984852Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.984859Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.984860Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.984862Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.984901Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.984905Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.984908Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.994317Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.994330Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:08.994369Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:08.994376Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:08.994377Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.994379Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:08.994388Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:08.994389Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.994390Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:08.994393Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:08.994390Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:08.994395Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:08.994397Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.015915Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.015932Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.015984Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:09.015991Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.015994Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.015995Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.016005Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.016008Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.016009Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.016010Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:09.016012Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.016014Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.025505Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.025519Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.025564Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:09.025570Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.025573Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.025575Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.025583Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.025584Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.025586Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.025588Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.025590Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:09.025593Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.025602Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.046847Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.046866Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.046919Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:09.046926Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.046929Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.046931Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.046942Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.046943Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.046945Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.046948Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:09.046950Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.046953Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.056277Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.056290Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.056331Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=92 +2026-06-16T07:14:09.056337Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.056339Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.056340Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 +2026-06-16T07:14:09.056349Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.056350Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.056352Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.056353Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=92 +2026-06-16T07:14:09.056354Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.056355Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=92 gossip_kind="update" gossip_payload_bytes=92 frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.056356Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=96 neighbors_only=false +2026-06-16T07:14:09.078044Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.078063Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.078117Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.078125Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.078128Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.078130Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.078141Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.078143Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.078145Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.078146Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.078147Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.078150Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.087582Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.087596Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.087642Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.087649Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.087652Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.087654Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.087660Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.087661Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.087663Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.087665Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.087671Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.087673Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.087675Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.103037Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.103048Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.153394Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.153412Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.153470Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.153478Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.153481Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.153484Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.153495Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.153496Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.153498Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.153502Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.153505Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.153509Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.162798Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.162827Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.162931Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.162939Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.162943Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.162946Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.162965Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.162965Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.162971Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.162974Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.162976Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.162977Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.162979Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.177342Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:09.177356Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:09.177364Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.177366Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.177368Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.177398Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.177402Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.190628Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.190639Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.202405Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.202413Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.224917Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.224939Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.225012Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.225020Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.225023Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.225025Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.225038Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.225039Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.225041Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.225040Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.225044Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.225047Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.234826Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.234841Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.234894Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.234901Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.234903Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.234904Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.234912Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.234914Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.234915Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.234917Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.234919Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.234921Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.234923Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.258446Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.258465Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.258526Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.258533Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.258537Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.258539Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.258547Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.258548Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.258550Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.258561Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.258564Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.258568Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.268393Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.268409Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.268458Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.268466Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.268469Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.268471Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.268482Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.268483Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.268485Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.268487Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.268517Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.268532Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.268544Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.290632Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.290650Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.290704Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.290712Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.290716Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.290718Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.290725Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.290726Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.290728Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.290735Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.290738Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.290741Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.300365Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.300382Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.300431Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.300439Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.300443Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.300445Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.300452Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.300453Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.300454Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.300457Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.300461Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.300463Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.300466Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.315607Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:09.315620Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:09.315628Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.315631Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.315633Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.315645Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.315649Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.328513Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.328521Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.328550Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.328551Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.377401Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.377411Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.396149Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.396193Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.396340Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.396351Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.396357Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.396361Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.396388Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.396391Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.396390Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.396393Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.396394Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.396398Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.405998Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.406019Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.406078Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.406085Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.406088Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.406089Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.406102Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.406104Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.406105Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.406106Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.406107Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.406108Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.406110Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.429452Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.429472Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.429534Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.429543Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.429546Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.429548Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.429558Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.429559Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.429561Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.429565Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.429568Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.429571Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.439101Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.439117Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.439178Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.439186Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.439190Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.439193Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.439199Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.439200Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.439202Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.439204Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.439209Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.439213Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.439216Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.460920Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.460938Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.460994Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.461003Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.461007Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.461009Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.461015Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.461017Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.461018Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.461042Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.461046Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.461049Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.470505Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.470520Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.470568Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.470575Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.470578Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.470579Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.470587Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.470588Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.470589Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.470592Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.470594Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.470597Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.470599Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.492186Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.492203Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.492260Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.492267Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.492270Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.492273Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.492280Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.492281Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.492283Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.492289Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.492294Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.492296Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.501778Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.501793Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.501837Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.501856Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.501857Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.501859Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.501861Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.501913Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.501924Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.501928Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.501951Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.501957Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.501963Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.536610Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.536628Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.536681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.536689Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.536692Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.536694Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.536704Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.536705Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.536707Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.536712Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.536715Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.536720Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.546245Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.546260Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.546306Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.546314Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.546317Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.546320Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.546326Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.546328Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.546329Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.546332Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.546333Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.546335Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.546337Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.569182Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.569200Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.569258Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.569265Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.569268Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.569270Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.569278Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.569279Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.569281Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.569289Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.569293Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.569295Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.578866Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.578880Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.578921Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.578927Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.578929Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.578931Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.578940Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.578941Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.578942Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.578942Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.578944Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.578944Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.578947Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.600525Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.600542Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.600594Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.600601Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.600604Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.600607Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.600615Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.600617Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.600619Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.600621Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.600623Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.600626Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.610095Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.610109Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.610154Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.610161Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.610163Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.610165Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.610174Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.610175Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.610176Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.610178Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.610179Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.610181Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.610183Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.632034Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.632060Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.632146Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.632153Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.632156Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.632158Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.632170Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.632173Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.632175Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.632175Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.632177Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.632180Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.641716Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.641734Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.641795Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.641804Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.641807Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.641809Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.641815Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.641816Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.641818Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.641821Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.641824Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.641828Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.641830Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.657072Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:09.657086Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:09.657094Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.657104Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.657106Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.657121Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.657126Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.676566Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.676586Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.676650Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.676657Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.676660Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.676661Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.676671Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.676672Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.676674Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.676676Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.676679Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.676682Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.686176Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.686193Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.686244Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.686251Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.686253Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.686255Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.686264Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.686265Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.686266Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.686267Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.686270Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.686269Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.686271Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.709365Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.709383Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.709442Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.709450Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.709453Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.709455Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.709469Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.709471Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.709473Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.709473Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.709476Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.709479Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.718992Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.719005Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.719049Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.719071Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.719073Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.719074Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.719076Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.719084Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.719087Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.719088Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.719103Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.719105Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.719108Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.740368Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.740385Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.740441Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.740449Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.740453Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.740455Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.740462Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.740463Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.740465Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.740471Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.740473Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.740476Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.749986Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.750001Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.750042Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.750049Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.750051Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.750053Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.750063Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.750064Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.750065Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.750067Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.750065Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.750070Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.750072Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.771377Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.771401Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.771488Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.771497Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.771500Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.771502Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.771512Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.771514Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.771516Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.771526Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.771530Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.771533Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.780798Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.780815Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.780871Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.780877Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.780880Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.780882Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.780890Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.780891Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.780893Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.780895Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.780896Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.780897Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.780900Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.815228Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.815247Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.815314Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.815320Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.815324Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.815326Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.815335Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.815336Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.815338Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.815338Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.815340Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.815343Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.824871Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.824886Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.824934Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.824940Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.824942Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.824943Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.824953Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.824954Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.824955Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.824956Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.824957Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.824958Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.824963Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.847919Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.847937Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.848000Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.848007Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.848009Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.848012Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.848022Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.848025Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.848027Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.848027Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.848030Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.848032Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.857599Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.857616Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.857667Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.857674Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.857677Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.857679Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.857688Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.857689Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.857691Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.857692Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.857694Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.857694Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.857697Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.879033Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.879051Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.879109Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.879116Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.879118Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.879120Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.879131Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.879134Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.879135Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.879136Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.879137Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.879139Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.888606Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.888619Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.888661Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.888667Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.888670Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.888672Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.888680Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.888681Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.888682Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.888684Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.888686Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.888688Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.888690Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.909712Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.909729Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.909782Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.909788Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.909790Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.909791Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.909802Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.909803Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.909805Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.909805Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.909809Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.909812Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.919285Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.919298Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.919340Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.919346Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.919348Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.919349Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.919359Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.919360Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.919360Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.919361Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.919362Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.919363Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.919364Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.940757Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.940774Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.940825Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.940832Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.940834Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.940837Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.940845Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.940846Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.940848Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.940854Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.940856Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.940858Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.950336Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.950347Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.950388Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.950394Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.950397Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.950398Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.950406Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.950407Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.950408Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.950410Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.950410Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.950412Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.950414Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.965200Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:09.965213Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:09.965225Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.965227Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.965229Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:09.965230Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:09.965233Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.965266Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.965271Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:09.984185Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.984204Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.984259Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.984267Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.984270Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.984274Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.984280Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.984281Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.984283Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.984307Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.984311Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.984314Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.993762Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.993776Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:09.993819Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:09.993826Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:09.993828Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.993829Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:09.993837Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:09.993838Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.993840Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:09.993842Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:09.993844Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:09.993846Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:09.993848Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.016198Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.016218Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:10.016286Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:10.016294Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:10.016297Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.016299Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.016313Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:10.016314Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.016316Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.016320Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:10.016323Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.016326Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.025744Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.025759Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:10.025810Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:10.025816Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:10.025819Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.025821Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.025829Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:10.025830Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.025832Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.025832Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:10.025835Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.025834Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.025842Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.047204Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.047222Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:10.047280Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:10.047288Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:10.047291Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.047292Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.047302Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:10.047303Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.047305Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.047309Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:10.047311Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.047314Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.056872Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.056884Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:10.056927Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:10.056933Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:10.056934Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.056935Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:10.056945Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:10.056946Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.056947Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.056947Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:10.056950Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:10.056949Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.056951Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:10.094338Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.094347Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.094375Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.094378Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.136016Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:10.136036Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:10.136057Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:10.136072Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:10.136080Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:10.136109Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:10.136116Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:10.210919Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.210930Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.220213Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.220224Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.229540Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.229553Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.239420Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.239435Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:10.496467Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:10.496496Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:11.027947Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:11.027975Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:11.559066Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:11.559088Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:12.090175Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:12.090196Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:12.620834Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:12.620856Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:13.151960Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:13.151987Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:13.683098Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:13.683126Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:13.741983Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 +2026-06-16T07:14:13.742024Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:14:13.742035Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:14:13.742041Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:13.742049Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:90: collaboration anti-entropy digest considered session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce relation=Equal our_vv_bytes=38 +2026-06-16T07:14:13.742054Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:115: collaboration anti-entropy digest needs no pull session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a relation=Equal +2026-06-16T07:14:13.742057Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:122: handled collaboration digest session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 relation=Equal action="none" +2026-06-16T07:14:14.214224Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:14.214251Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:14.335458Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:169: collaboration digest timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 inbound_since_last_digest=true +2026-06-16T07:14:14.335499Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 +2026-06-16T07:14:14.335507Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:216: finished collaboration digest round session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 +2026-06-16T07:14:14.335519Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:14.335530Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:14.335535Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:14:14.335537Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:59: collaboration anti-entropy digest sent session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce next_digest_in=11.911s +2026-06-16T07:14:14.335542Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:14:14.335590Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 neighbors_only=true +2026-06-16T07:14:14.335604Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=74 neighbors_only=true +2026-06-16T07:14:14.745380Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:14.745409Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:15.276522Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:15.276549Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:15.807663Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:15.807690Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:16.094264Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:183: collaboration presence timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:16.094324Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:16.094349Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:16.094367Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:16.094375Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:16.094380Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:16.094388Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:16.094404Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:16.094444Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:16.094468Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:16.147116Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 frame_bytes=148 +2026-06-16T07:14:16.147163Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:14:16.147173Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:14:16.147178Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:16.147181Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:13: applying remote collaboration presence update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=143 +2026-06-16T07:14:16.147206Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:16.147230Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:16.338324Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:16.338351Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:16.869469Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:16.869498Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:17.400350Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:17.400376Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:17.931484Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:17.931512Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:18.462627Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:18.462649Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:18.993765Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:18.993793Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:19.524903Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:19.524931Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:20.055379Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:20.055407Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:20.586521Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:20.586549Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:21.117639Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:21.117660Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:21.648745Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:21.648767Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:22.179876Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:22.179904Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:24.599816Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 +2026-06-16T07:14:24.599857Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:14:24.599866Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="digest" gossip_payload_bytes=38 +2026-06-16T07:14:24.599871Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:24.599880Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:90: collaboration anti-entropy digest considered session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce relation=Equal our_vv_bytes=38 +2026-06-16T07:14:24.599885Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:115: collaboration anti-entropy digest needs no pull session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a relation=Equal +2026-06-16T07:14:24.599888Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:122: handled collaboration digest session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a digest_session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 relation=Equal action="none" +2026-06-16T07:14:24.702518Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:24.702547Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:24.743998Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:24.744010Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:25.011050Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:25.011079Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:25.344194Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:25.344212Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:26.095534Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:183: collaboration presence timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.095576Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:26.095592Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:26.095607Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:26.095625Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:26.095668Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:26.095675Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:26.095681Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:26.095709Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:26.095719Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:26.148811Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:167: received collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 frame_bytes=148 +2026-06-16T07:14:26.148851Z TRACE main ThreadId(01) flowstate::collab::pump: crates/flowstate/src/collab/pump.rs:48: routing collaboration gossip event session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:14:26.148861Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:706: handling collaboration gossip message session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce from=60574d8fd22067ee0cbb6e885b3ef050a397e3f50003077e452b2bd153c58f9a gossip_kind="presence" gossip_payload_bytes=143 +2026-06-16T07:14:26.148866Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:34: noted inbound collaboration network traffic session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.148869Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:13: applying remote collaboration presence update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=143 +2026-06-16T07:14:26.148894Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:26.148917Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:26.247574Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:169: collaboration digest timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 inbound_since_last_digest=true +2026-06-16T07:14:26.247595Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:181: queued collaboration digest publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce vv_bytes=38 +2026-06-16T07:14:26.247597Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:216: finished collaboration digest round session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce quiet_rounds=0 +2026-06-16T07:14:26.247606Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:26.247605Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:26.247608Z TRACE main ThreadId(01) flowstate_collab::net::anti_entropy: crates/flowstate-collab/src/net/anti_entropy.rs:59: collaboration anti-entropy digest sent session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce next_digest_in=11.091s +2026-06-16T07:14:26.247611Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:14:26.247614Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 +2026-06-16T07:14:26.247638Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" payload_bytes=38 gossip_kind="digest" gossip_payload_bytes=38 frame_bytes=74 neighbors_only=true +2026-06-16T07:14:26.247643Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="digest" frame_bytes=74 neighbors_only=true +2026-06-16T07:14:26.761301Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:26.761324Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:26.977137Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.977164Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:26.977245Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:26.977255Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:26.977260Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:26.977263Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:26.977271Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:26.977272Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.977275Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:26.977289Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:26.977293Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:26.977297Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:26.986960Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.986975Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:26.987023Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:26.987030Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:26.987033Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:26.987035Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:26.987042Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:26.987043Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.987045Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:26.987048Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:26.987051Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:26.987053Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:26.987055Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.010532Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.010552Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.010614Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.010621Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.010625Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.010626Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.010636Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.010638Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.010640Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.010646Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.010649Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.010652Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.020366Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.020381Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.020432Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.020440Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.020441Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.020443Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.020451Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.020452Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.020454Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.020454Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.020456Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.020456Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.020458Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.035541Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.035550Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.086130Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:27.086150Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:27.086165Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.086167Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.086170Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.086190Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.086196Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.181239Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.181252Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.190905Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.190914Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.199640Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.199651Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.208957Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.208965Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.270346Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.270373Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.270441Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.270449Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.270455Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.270457Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.270467Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.270469Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.270471Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.270488Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.270494Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.270498Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.279964Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.279994Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.280075Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.280082Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.280085Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.280087Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.280095Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.280097Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.280098Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.280101Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.280103Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.280105Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.280108Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.298673Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.298681Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.308564Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.308575Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.319291Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.319299Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.347020Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:27.347039Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:27.347049Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.347052Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.347055Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.347069Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.347075Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.414962Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.414986Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.415055Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.415063Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.415067Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.415069Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.415077Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.415078Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.415079Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.415099Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.415103Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.415106Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.424614Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.424631Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.424689Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.424696Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.424699Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.424700Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.424710Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.424711Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.424713Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.424716Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.424718Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.424721Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.424723Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.450687Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.450696Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.459348Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.459356Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.489261Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:27.489281Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:27.489292Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.489296Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.489300Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.489319Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.489325Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.512027Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.512048Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.512147Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.512154Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.512158Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.512160Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.512169Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.512171Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.512173Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.512191Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.512194Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.512197Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.522070Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.522086Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.522143Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.522150Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.522153Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.522155Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.522164Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.522165Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.522167Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.522169Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.522170Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.522172Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.522174Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.548138Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.548149Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.560931Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.560940Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.586426Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:27.586443Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:27.586452Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.586455Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.586459Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.586475Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.586480Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.686050Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.686063Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.694870Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.694880Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.703630Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.703642Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.713178Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.713190Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.761970Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.761996Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.762077Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.762085Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.762092Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.762093Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.762101Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.762103Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.762105Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.762112Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.762114Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.762118Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.771504Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.771529Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.771620Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.771629Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.771634Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.771636Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.771648Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.771651Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.771653Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.771656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.771672Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.771677Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.771681Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.797736Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.797747Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.811127Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.811137Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.836058Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.836066Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.836081Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:27.836090Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:27.836104Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.836108Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.836110Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.836126Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.836131Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.911870Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.911897Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.911974Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.911982Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.911986Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.911988Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.912000Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.912003Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.912005Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.912006Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.912008Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.912012Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.921556Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.921575Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:27.921638Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:27.921644Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.921647Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.921649Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:27.921659Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:27.921660Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.921662Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.921663Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:27.921665Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:27.921665Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.921668Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:27.947608Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.947616Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.960959Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:27.960967Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:27.994366Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:27.994384Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:27.994399Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:27.994402Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.994405Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:27.994421Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:27.994430Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.012109Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.012134Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.012213Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.012221Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.012224Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.012225Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.012236Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.012238Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.012239Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.012240Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.012242Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.012245Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.021784Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.021810Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.021899Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.021908Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.021912Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.021914Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.021928Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.021931Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.021932Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.021935Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.021950Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.021955Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.021958Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.048194Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.048206Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.061040Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.061048Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.086184Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:28.086200Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:28.086205Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.086208Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.086211Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.086236Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.086241Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.136933Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.136956Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.137024Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.137032Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.137037Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.137040Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.137049Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.137052Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.137054Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.137056Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.137058Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.137061Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.146449Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.146474Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.146562Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.146570Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.146575Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.146577Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.146592Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.146594Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.146597Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.146601Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.146608Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.146611Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.146615Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.172470Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.172478Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.186123Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.186131Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.219517Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:28.219533Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:28.219548Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.219551Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.219553Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.219571Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.219575Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.245495Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.245521Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.245595Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.245602Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.245605Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.245607Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.245616Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.245618Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.245620Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.245623Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.245626Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.245628Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.255383Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.255407Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.255478Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.255486Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.255489Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.255491Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.255500Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.255503Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.255504Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.255504Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.255505Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.255507Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.255507Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.281654Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.281663Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.294406Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.294415Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.320140Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:28.320157Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:28.320166Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.320168Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.320171Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.320184Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.320187Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.369486Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.369495Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.411146Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.411156Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.424520Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.424529Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.433735Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.433742Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.451899Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.451921Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.451994Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.451999Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.452003Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.452008Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.452016Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.452017Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.452020Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.452028Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.452031Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.452034Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.461705Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.461721Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.461780Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.461783Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.461786Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.461787Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.461802Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.461801Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.461803Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.461803Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.461804Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.461806Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.461807Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.484783Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.484802Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.484867Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.484872Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.484876Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.484879Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.484889Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.484890Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.484892Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.484900Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.484903Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.484907Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.494666Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.494682Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.494733Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.494739Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.494742Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.494743Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.494752Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.494753Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.494755Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.494758Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.494758Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.494760Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.494762Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.509773Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.509782Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.561082Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:28.561102Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:28.561111Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.561115Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.561117Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.561136Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.561141Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.603974Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.603996Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.604069Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.604078Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.604083Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.604086Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.604095Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.604096Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.604098Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.604104Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.604108Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.604112Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.613812Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.613829Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.613890Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.613894Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.613896Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.613898Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.613913Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.613914Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.613916Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.613916Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.613918Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.613919Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.613921Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.640555Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.640564Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.652965Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.652976Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.674455Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.674474Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.674541Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.674549Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.674552Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.674554Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.674564Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.674565Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.674566Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.674568Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.674571Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.674574Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.684218Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.684235Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.684290Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.684293Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.684295Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.684298Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.684310Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.684311Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.684313Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.684314Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.684315Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.684315Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.684318Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.697969Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:28.697983Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:28.697988Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.697991Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.697993Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.698011Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.698017Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.710582Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.710594Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.719372Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.719380Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.831617Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.831643Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.831709Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.831717Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.831721Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.831723Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.831732Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.831733Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.831735Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.831754Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.831760Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.831763Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.841279Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.841296Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.841352Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.841359Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.841361Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.841364Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.841372Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.841373Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.841375Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.841378Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.841381Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.841386Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.841388Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.867324Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.867333Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.877769Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.877777Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.912108Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.912129Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.912204Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.912212Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.912217Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.912218Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.912227Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.912229Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.912231Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.912237Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.912241Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.912244Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.921721Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.921738Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.921795Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.921803Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.921806Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.921808Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.921816Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.921817Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.921818Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.921821Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.921824Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.921827Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.921831Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.921933Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.921936Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.921947Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:28.921954Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:28.921957Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.921958Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.921960Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:28.921969Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.921972Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:28.947918Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:28.947927Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.966255Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.966278Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.966342Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.966349Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.966352Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.966354Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.966366Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.966369Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.966370Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.966376Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.966379Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.966382Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.975946Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.975965Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:28.976023Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:28.976030Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:28.976033Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.976035Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:28.976045Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:28.976047Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.976049Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:28.976049Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:28.976051Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:28.976052Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:28.976053Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.002165Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.002173Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.002204Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.002206Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.044510Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:29.044529Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:29.044558Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.044562Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.044565Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.044583Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.044587Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.103751Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.103777Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.103861Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.103867Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.103870Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.103872Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.103884Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.103887Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.103889Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.103889Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.103892Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.103895Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.113610Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.113640Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.113731Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.113740Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.113745Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.113747Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.113763Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.113764Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.113766Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.113769Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.113812Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.113831Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.113840Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.140233Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.140245Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.152730Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.152738Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.178587Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:29.178604Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:29.178611Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.178614Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.178617Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.178633Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.178635Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.195414Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.195438Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.195507Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.195515Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.195520Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.195522Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.195532Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.195534Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.195536Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.195540Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.195543Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.195547Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.205261Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.205278Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.205338Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.205345Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.205348Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.205350Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.205359Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.205360Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.205362Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.205364Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.205365Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.205367Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.205370Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.231395Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.231406Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.244351Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.244359Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.277805Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:29.277822Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:29.277861Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.277881Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.277888Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.277919Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.277930Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.369572Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.369581Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.378372Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.378380Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.387187Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.387195Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.396685Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.396695Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.428827Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.428849Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.428918Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.428925Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.428929Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.428930Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.428940Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.428942Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.428944Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.428948Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.428950Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.428961Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.438384Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.438399Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.438460Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.438467Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.438470Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.438472Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.438483Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.438485Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.438487Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.438488Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.438490Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.438490Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.438493Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.465008Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.465022Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.465046Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.465048Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.477654Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.477664Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.503309Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:29.503327Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:29.503337Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.503341Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.503345Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.503362Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.503367Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.602735Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.602746Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.615924Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.615936Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.624944Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.624953Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.634500Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.634508Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.670273Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.670297Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.670373Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.670381Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.670385Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.670387Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.670395Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.670397Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.670398Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.670409Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.670414Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.670417Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.679998Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.680034Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.680172Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.680180Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.680185Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.680187Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.680213Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.680218Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.680218Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.680219Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.680222Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.680223Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.680228Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.706640Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.706648Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.715434Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.715442Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.744802Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:29.744817Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:29.744824Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.744828Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.744830Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.744842Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.744845Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.824680Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.824707Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.824786Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.824794Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.824801Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.824803Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.824809Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.824811Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.824812Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.824826Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.824831Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.824834Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.834317Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.834334Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.834393Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.834400Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.834403Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.834405Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.834414Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.834417Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.834419Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.834419Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.834422Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.834422Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.834425Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.860583Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.860592Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.869432Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.869442Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.891677Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.891699Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.891780Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.891787Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.891790Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.891793Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.891804Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.891805Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.891808Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.891810Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.891813Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.891816Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.901364Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.901382Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:29.901444Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:29.901451Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.901453Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.901455Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:29.901465Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:29.901466Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.901468Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.901468Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:29.901471Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:29.901471Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.901473Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:29.915175Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:29.915189Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:29.915202Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:29.915204Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.915205Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:29.915217Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.915221Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:29.927696Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.927704Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.936262Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.936269Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:29.995103Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:29.995113Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.053802Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.053828Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.053899Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.053906Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.053909Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.053912Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.053921Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.053923Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.053924Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.053938Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.053944Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.053947Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.063576Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.063601Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.063673Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.063682Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.063685Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.063687Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.063704Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.063706Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.063708Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.063709Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.063709Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.063712Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.063712Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.089830Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.089841Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.102758Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.102767Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.124462Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.124481Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.124556Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.124564Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.124568Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.124570Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.124583Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.124584Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.124586Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.124623Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.124628Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.124631Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.134133Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.134147Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.134199Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.134206Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.134209Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.134210Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.134220Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.134221Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.134223Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.134223Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.134225Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.134225Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.134227Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.147793Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:30.147807Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:30.147815Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.147819Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.147821Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.147836Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.147840Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.160203Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.160211Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.169425Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.169434Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.286118Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.286127Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.299353Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.299363Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.308270Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.308279Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.317693Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.317701Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.403819Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.403843Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.403919Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.403928Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.403932Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.403934Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.403942Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.403943Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.403945Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.403959Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.403963Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.403967Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.413502Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.413529Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.413615Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.413625Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.413630Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.413633Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.413647Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.413650Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.413652Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.413654Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.413656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.413656Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.413659Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.436931Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.436951Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.437016Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.437023Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.437026Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.437028Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.437039Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.437040Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.437042Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.437059Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.437064Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.437068Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.446522Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.446542Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.446609Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.446618Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.446621Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.446623Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.446633Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.446634Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.446637Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.446639Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.446640Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.446643Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.446646Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.483747Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.483767Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.483836Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.483844Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.483848Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.483851Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.483857Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.483858Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.483860Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.483871Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.483877Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.483880Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.493406Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.493424Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.493484Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.493493Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.493497Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.493498Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.493505Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.493506Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.493508Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.493511Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.493515Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.493517Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.493520Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.507206Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:30.507220Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:30.507229Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.507231Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.507233Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.507250Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.507254Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.529260Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.529282Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.529345Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.529353Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.529356Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.529359Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.529368Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.529369Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.529371Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.529375Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.529379Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.529382Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.539013Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.539029Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.539086Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.539092Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.539097Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.539099Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.539105Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.539107Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.539108Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.539111Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.539111Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.539112Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.539115Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.552663Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.552671Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.565496Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.565507Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.565548Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.565550Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.603624Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:30.603644Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:30.603652Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.603657Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.603659Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.603675Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.603679Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.649270Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.649297Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.649358Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.649365Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.649368Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.649371Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.649380Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.649382Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.649384Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.649388Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.649390Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.649393Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.658947Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.658966Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.659022Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.659029Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.659032Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.659034Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.659043Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.659045Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.659046Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.659048Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.659050Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.659050Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.659053Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.682237Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.682264Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.682349Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.682356Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.682359Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.682360Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.682373Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.682374Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.682376Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.682378Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.682381Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.682384Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.691946Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.691962Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.692014Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.692020Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.692022Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.692024Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.692034Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.692035Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.692036Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.692036Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.692038Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.692039Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.692040Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.719505Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.719513Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.719546Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.719548Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.736234Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:30.736249Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:30.736257Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.736259Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.736261Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.736278Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.736283Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.745681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.745700Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.745770Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.745777Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.745779Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.745780Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.745792Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.745792Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.745793Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.745794Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.745795Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.745796Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.755447Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.755484Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.755621Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.755631Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.755638Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.755642Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.755667Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.755670Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.755673Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.755672Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.755675Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.755677Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.755680Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.778182Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.778209Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.778294Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.778303Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.778310Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.778313Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.778319Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.778321Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.778323Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.778336Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.778338Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.778342Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.787710Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.787727Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.787783Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.787790Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.787793Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.787795Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.787807Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.787809Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.787810Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.787813Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.787814Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.787815Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.787818Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.824154Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.824166Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.850006Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:30.850027Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:30.850034Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.850037Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.850040Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.850053Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.850057Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.890200Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.890229Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.890310Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.890319Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.890323Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.890326Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.890336Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.890338Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.890340Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.890361Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.890366Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.890369Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.899769Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.899788Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:30.899854Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:30.899861Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.899864Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.899866Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:30.899878Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:30.899880Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.899882Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.899884Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:30.899898Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:30.899902Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.899905Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:30.926094Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.926103Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.936131Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:30.936139Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:30.964640Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:30.964667Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:30.964675Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:30.964678Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.964681Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:30.964695Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:30.964699Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.007060Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.007084Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.007150Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.007157Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.007161Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.007163Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.007173Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.007175Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.007177Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.007184Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.007186Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.007190Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.016637Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.016655Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.016713Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.016720Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.016723Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.016725Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.016732Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.016733Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.016735Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.016738Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.016740Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.016742Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.016745Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.042910Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.042921Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.053025Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.053034Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.086051Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:31.086070Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:31.086101Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.086105Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.086100Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.086124Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.086146Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.086166Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.086172Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.154007Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.154033Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.154116Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.154123Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.154128Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.154130Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.154140Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.154141Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.154144Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.154148Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.154151Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.154154Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.163827Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.163846Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.163910Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.163917Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.163920Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.163922Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.163933Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.163935Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.163937Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.163936Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.163939Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.163939Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.163942Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.190549Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.190561Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.211876Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.211897Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.211973Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.211982Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.211988Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.211991Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.211997Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.211998Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.212000Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.212011Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.212015Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.212018Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.221474Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.221490Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.221550Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.221557Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.221560Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.221561Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.221570Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.221571Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.221573Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.221576Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.221575Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.221577Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.221580Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.238195Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:31.238209Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:31.238213Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.238217Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.238219Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.238236Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.238240Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.257901Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.257923Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.257989Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.257997Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.258000Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.258003Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.258010Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.258012Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.258013Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.258020Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.258025Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.258028Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.267561Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.267578Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.267636Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.267644Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.267647Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.267649Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.267656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.267657Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.267659Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.267662Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.267664Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.267666Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.267669Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.293858Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.293868Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.302857Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.302865Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.331546Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:31.331563Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:31.331572Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.331576Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.331578Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.331595Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.331599Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.387214Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.387241Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.387311Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.387319Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.387322Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.387325Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.387334Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.387336Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.387338Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.387340Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.387342Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.387345Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.397201Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.397218Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.397278Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.397285Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.397290Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.397291Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.397299Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.397301Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.397302Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.397305Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.397322Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.397325Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.397327Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.423780Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.423788Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.432885Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.432893Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.466239Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.466258Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.466328Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.466336Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.466340Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.466342Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.466353Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.466355Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.466357Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.466362Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.466365Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.466374Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.475770Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.475789Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.475850Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.475857Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.475860Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.475863Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.475873Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.475874Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.475876Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.475879Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.475879Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.475881Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.475884Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.489501Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:31.489515Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:31.489531Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.489538Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.489540Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.489559Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.489563Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.501990Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.501998Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.511078Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.511088Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.628829Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.628855Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.628937Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.628945Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.628948Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.628950Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.628963Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.628964Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.628966Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.628967Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.628970Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.628973Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.638488Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.638506Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.638572Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.638579Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.638582Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.638584Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.638592Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.638594Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.638595Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.638599Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.638600Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.638602Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.638605Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.638716Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.638718Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.664656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.664667Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.673711Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.673719Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.704656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.704674Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.704746Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.704753Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.704757Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.704759Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.704767Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.704769Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.704770Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.704782Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.704786Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.704789Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.714519Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.714547Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.714634Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.714643Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.714647Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.714649Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.714664Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.714665Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.714667Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.714670Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.714673Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.714676Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.714679Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.728481Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:31.728496Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:31.728519Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.728535Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.728542Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.728579Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.728582Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.731214Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.731220Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.731243Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.731245Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.870335Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.870361Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.870440Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.870448Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.870453Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.870455Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.870465Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.870467Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.870469Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.870479Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.870483Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.870487Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.880091Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.880118Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.880201Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.880210Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.880213Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.880215Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.880222Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.880223Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.880225Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.880228Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.880229Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.880232Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.880234Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.906537Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.906547Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.919293Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:31.919301Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.941573Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.941595Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.941665Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.941673Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.941676Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.941679Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.941688Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.941690Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.941692Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.941698Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.941700Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.941703Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.951215Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.951232Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.951292Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.951301Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.951304Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.951306Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.951316Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.951317Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.951319Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.951321Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.951323Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.951323Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.951327Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.974656Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.974677Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.974738Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.974746Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.974749Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.974750Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.974760Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.974762Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.974763Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.974772Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.974774Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.974777Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.984426Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.984441Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:31.984492Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:31.984499Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.984501Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.984504Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:31.984512Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:31.984513Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.984515Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:31.984517Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:31.984520Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:31.984522Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.984524Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:31.996875Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:31.996889Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:31.996908Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:31.996931Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.996933Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:31.996953Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:31.996955Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.020728Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.020737Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.095073Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.095095Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.095170Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.095177Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.095180Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.095181Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.095192Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.095193Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.095195Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.095201Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.095204Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.095215Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.104578Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.104606Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.104696Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.104704Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.104707Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.104710Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.104727Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.104727Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.104734Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.104736Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.104740Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.104743Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.104748Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.130790Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.130800Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.149248Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.149270Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.149338Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.149345Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.149349Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.149351Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.149360Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.149361Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.149363Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.149365Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.149368Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.149371Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.158865Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.158885Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.158945Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.158952Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.158955Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.158956Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.158965Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.158966Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.158968Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.158970Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.158972Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.158975Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.158977Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.181914Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.181943Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.182033Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.182043Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.182049Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.182054Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.182065Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.182067Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.182068Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.182077Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.182080Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.182082Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.191572Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.191587Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.191637Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.191643Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.191646Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.191647Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.191655Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.191657Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.191659Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.191661Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.191662Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.191664Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.191666Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.206677Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:32.206691Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:32.206700Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.206703Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:32.206705Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:32.206706Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.206709Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.206719Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.206724Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.227864Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.227871Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.236813Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.236820Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.320849Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.320871Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.320934Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.320942Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.320945Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.320947Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.320957Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.320959Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.320961Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.320964Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.320966Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.320969Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.330700Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.330737Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.330856Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.330868Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.330871Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.330873Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.330884Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.330885Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.330887Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.330890Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.330907Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.330912Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.330915Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.353596Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.353614Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.353681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.353688Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.353691Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.353693Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.353703Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.353705Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.353706Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.353712Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.353714Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.353717Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.363544Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.363561Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.363611Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.363618Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.363621Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.363624Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.363633Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.363634Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.363636Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.363638Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.363641Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.363647Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.363650Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.398133Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.398153Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.398217Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.398224Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.398228Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.398230Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.398239Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.398240Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.398242Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.398246Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.398248Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.398251Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.407785Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.407821Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.407907Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.407913Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.407916Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.407918Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.407928Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.407930Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.407932Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.407934Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.407957Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.407959Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.407962Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.430622Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.430642Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.430701Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.430708Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.430711Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.430713Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.430722Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.430723Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.430725Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.430730Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.430734Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.430737Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.440256Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.440269Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.440314Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.440320Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.440322Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.440324Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.440333Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.440334Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.440336Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.440338Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.440355Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.440359Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.440361Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.455980Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:32.455992Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:32.455996Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.455998Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:32.455999Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:32.456014Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.456017Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.468548Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.468556Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.594655Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.594663Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.607681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.607693Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.616438Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.616450Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.626007Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.626014Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.737750Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:32.737760Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.837001Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.837030Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.837107Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.837115Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.837120Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.837123Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.837132Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.837134Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.837136Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.837159Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.837163Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.837167Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.926117Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.926141Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.926222Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.926229Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.926233Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.926235Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.926245Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.926247Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.926249Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.926258Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.926261Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.926265Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.950843Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.950866Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.950943Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.950951Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.950955Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.950958Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.950969Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.950971Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.950973Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.950981Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.950985Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.950988Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.971107Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:32.971122Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:32.971130Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.971134Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:32.971137Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:32.971152Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.971157Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:32.993895Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.993918Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:32.993995Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:32.994003Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:32.994007Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.994009Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:32.994017Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:32.994019Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:32.994021Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:32.994030Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:32.994032Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:32.994036Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.017682Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.017706Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.017784Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.017793Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.017798Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.017800Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.017806Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.017808Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.017810Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.017820Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.017824Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.017827Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.052677Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.052696Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.052765Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.052773Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.052776Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.052779Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.052787Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.052788Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.052790Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.052794Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.052797Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.052801Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.089857Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.089879Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.089960Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.089970Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.089974Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.089976Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.089984Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.089985Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.089987Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.090005Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.090008Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.090011Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.113313Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.113334Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.113418Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.113426Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.113431Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.113433Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.113441Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.113442Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.113444Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.113456Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.113460Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.113465Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.128984Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.128999Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.129007Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.129010Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.129013Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.129027Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.129031Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.148590Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.148612Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.148682Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.148685Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.148689Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.148690Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.148704Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.148706Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.148708Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.148711Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.148714Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.148717Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.185046Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.185068Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.185140Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.185147Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.185150Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.185152Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.185162Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.185164Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.185166Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.185176Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.185179Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.185182Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.221924Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.221950Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.222035Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.222039Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.222042Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.222044Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.222059Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.222061Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.222063Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.222064Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.222067Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.222070Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.239196Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.239214Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.239222Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.239227Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.239229Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.239250Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.239255Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.258508Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.258532Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.258604Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.258608Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.258611Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.258613Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.258628Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.258630Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.258630Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.258632Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.258633Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.258637Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.294800Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.294819Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.294892Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.294899Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.294902Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.294905Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.294914Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.294915Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.294917Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.294928Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.294932Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.294936Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.318653Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.318673Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.318745Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.318753Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.318757Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.318759Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.318767Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.318769Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.318771Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.318790Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.318797Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.318801Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.319410Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:33.319414Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.334301Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.334316Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.334324Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.334327Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.334329Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.334344Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.334347Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.354065Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.354094Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.354185Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.354195Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.354200Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.354203Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.354206Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.354208Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.354210Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.354224Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.354226Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.354229Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.381283Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.381308Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.381389Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.381397Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.381401Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.381403Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.381414Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.381415Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.381417Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.381422Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.381425Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.381428Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.406681Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.406704Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.406777Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.406784Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.406788Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.406789Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.406798Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.406800Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.406802Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.406809Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.406812Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.406816Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.434878Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.434895Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.434903Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.434907Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.434910Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.434923Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:33.434925Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.434930Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.434934Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.453237Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.453264Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.453344Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.453353Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.453357Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.453359Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.453367Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.453368Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.453370Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.453377Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.453380Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.453383Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.490427Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.490456Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.490546Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.490554Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.490557Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.490559Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.490569Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.490571Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.490573Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.490579Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.490582Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.490586Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.514267Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.514291Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.514371Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.514380Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.514382Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.514385Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.514396Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.514397Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.514399Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.514407Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.514411Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.514413Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.549176Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.549197Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.549272Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.549280Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.549283Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.549285Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.549295Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.549296Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.549298Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.549307Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.549311Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.549315Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.549940Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.549948Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.549956Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.549960Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.549962Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.549974Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.549977Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.585399Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.585424Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.585502Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.585525Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.585526Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.585529Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.585523Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.585534Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.585537Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.585561Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.585566Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.585573Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.622070Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.622091Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.622170Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.622177Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.622181Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.622183Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.622192Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.622194Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.622196Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.622206Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.622209Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.622213Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.658183Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.658207Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.658280Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.658287Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.658291Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.658293Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.658303Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.658304Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.658306Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.658309Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.658311Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.658314Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.675192Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.675208Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.675215Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.675217Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.675220Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.675238Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.675241Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.694758Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.694781Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.694851Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.694858Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.694863Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.694865Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.694873Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.694875Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.694877Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.694881Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.694882Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.694886Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.718518Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.718540Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.718617Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.718624Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.718629Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.718631Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.718640Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.718641Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.718643Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.718651Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.718655Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.718659Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.753657Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.753687Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.753758Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.753767Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.753771Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.753774Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.753781Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.753782Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.753784Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.753800Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.753805Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.753809Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.780446Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.780479Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.780582Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.780588Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.780593Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.780595Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.780617Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.780619Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.780621Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.780622Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.780623Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.780626Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.805807Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.805831Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.805914Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.805922Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.805926Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.805928Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.805938Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.805940Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.805941Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.805945Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.805947Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.805950Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.806757Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.806765Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.806772Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.806775Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.806777Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.806787Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.806791Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.834338Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:33.834349Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.852340Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.852363Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.852442Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.852452Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.852457Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.852459Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.852466Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.852467Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.852470Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.852475Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.852479Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.852483Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.866800Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:33.866808Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.886087Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.886110Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.886195Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.886203Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.886207Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.886209Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.886217Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.886219Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.886221Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.886225Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.886227Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.886235Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.912761Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.912798Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.912903Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.912912Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.912916Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.912919Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.912935Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.912938Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.912940Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.912945Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.912948Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.912952Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.938216Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.938238Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.938314Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.938322Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.938327Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.938329Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.938337Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.938340Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.938342Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.938348Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.938351Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.938355Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.939122Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:33.939131Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:33.939153Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.939169Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.939174Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:33.939195Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.939197Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:33.976108Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.976130Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:33.976209Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:33.976216Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:33.976220Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.976223Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:33.976230Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:33.976232Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:33.976234Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:33.976244Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:33.976248Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:33.976252Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.000228Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.000247Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.000320Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.000328Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.000331Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.000332Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.000341Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.000342Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.000344Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.000348Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.000350Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.000353Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.038016Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.038042Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.038117Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.038126Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.038130Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.038132Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.038141Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.038142Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.038144Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.038155Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.038159Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.038163Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.055205Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.055222Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.055232Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.055234Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.055237Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.055258Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.055263Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.074600Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.074623Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.074698Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.074707Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.074711Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.074714Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.074724Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.074725Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.074727Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.074735Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.074739Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.074742Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.102023Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:34.102033Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.102065Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:34.102068Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.120711Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.120740Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.120836Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.120866Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.120868Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.120871Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.120880Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.120904Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.120909Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.120963Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.120971Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.120976Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.157646Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.157670Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.157742Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.157750Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.157755Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.157758Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.157765Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.157766Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.157768Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.157777Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.157781Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.157785Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.181533Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.181553Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.181624Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.181633Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.181637Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.181638Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.181647Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.181648Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.181650Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.181676Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.181681Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.181684Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.182283Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.182292Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.182301Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.182302Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.182304Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.182327Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.182331Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.216293Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.216315Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.216393Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.216400Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.216403Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.216406Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.216414Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.216416Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.216417Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.216426Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.216432Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.216436Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.252037Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.252063Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.252148Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.252172Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.252173Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.252175Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.252158Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.252197Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.252203Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.252236Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.252240Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.252245Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.288644Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.288667Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.288750Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.288758Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.288763Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.288765Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.288773Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.288774Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.288777Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.288780Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.288783Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.288786Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.305663Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.305683Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.305690Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.305694Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.305696Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.305713Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.305717Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.325006Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.325029Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.325109Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.325118Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.325122Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.325125Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.325136Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.325137Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.325139Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.325144Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.325147Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.325151Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.361773Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.361793Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.361865Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.361873Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.361877Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.361879Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.361888Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.361889Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.361891Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.361894Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.361897Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.361900Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.385825Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.385847Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.385919Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.385927Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.385930Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.385933Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.385941Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.385942Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.385944Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.385952Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.385956Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.385959Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.401565Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.401584Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.401591Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.401595Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.401596Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.401602Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:34.401604Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.401613Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.401616Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.420791Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.420816Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.420889Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.420898Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.420901Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.420903Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.420912Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.420913Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.420915Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.420922Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.420924Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.420927Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.447639Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.447675Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.447779Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.447791Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.447796Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.447799Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.447811Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.447813Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.447816Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.447817Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.447821Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.447825Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.472526Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.472551Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.472636Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.472645Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.472648Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.472650Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.472659Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.472660Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.472662Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.472672Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.472674Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.472678Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.500561Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.500581Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.500587Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.500592Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.500594Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.500626Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.500631Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.500667Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:34.500672Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.500702Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:34.500704Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.520459Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.520493Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.520613Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.520622Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.520627Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.520630Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.520644Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.520646Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.520649Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.520658Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.520665Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.520670Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.557236Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.557258Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.557330Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.557338Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.557341Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.557343Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.557354Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.557356Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.557358Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.557372Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.557381Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.557384Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.580940Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.580961Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.581039Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.581047Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.581051Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.581053Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.581062Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.581063Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.581065Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.581070Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.581073Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.581076Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.615818Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.615837Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.615914Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.615921Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.615925Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.615928Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.615937Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.615938Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.615940Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.615947Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.615951Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.615955Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.616674Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.616683Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.616703Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.616713Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.616717Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.616740Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.616743Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.652277Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.652300Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.652385Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.652394Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.652397Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.652399Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.652408Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.652410Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.652412Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.652420Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.652423Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.652426Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.688903Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.688924Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.688994Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.689002Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.689005Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.689008Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.689019Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.689021Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.689023Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.689024Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.689026Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.689029Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.725205Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.725225Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.725300Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.725308Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.725311Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.725313Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.725322Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.725325Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.725327Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.725327Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.725329Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.725332Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.742323Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.742343Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.742350Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.742353Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.742354Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.742372Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.742377Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.762194Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.762229Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.762333Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.762343Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.762347Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.762350Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.762365Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.762367Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.762369Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.762370Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.762373Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.762377Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.785896Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.785916Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.785990Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.785997Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.786001Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.786003Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.786015Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.786016Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.786019Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.786022Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.786026Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.786029Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.823699Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.823719Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.823794Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.823800Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.823803Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.823805Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.823814Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.823815Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.823817Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.823852Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.823856Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.823860Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.847623Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.847648Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.847731Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.847736Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.847739Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.847742Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.847753Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.847755Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.847757Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.847763Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.847765Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.847768Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.873021Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.873044Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.873110Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.873118Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.873122Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.873125Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.873134Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.873136Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.873137Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.873143Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.873146Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.873154Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.874062Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.874072Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.874094Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.874112Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.874117Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.874141Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.874145Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.911801Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.911822Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.911896Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.911929Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.911932Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.911934Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.911940Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.911960Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.911968Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.911995Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.912000Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.912004Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.948553Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.948579Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.948662Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.948671Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.948674Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.948676Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.948692Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.948693Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.948698Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.948697Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.948700Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.948700Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.949556Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:34.949559Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.972953Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.972977Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:34.973051Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:34.973059Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.973062Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.973064Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:34.973073Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:34.973075Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:34.973077Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:34.973084Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:34.973087Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.973090Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:34.988844Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:34.988859Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:34.988863Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:34.988866Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.988868Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:34.988883Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:34.988886Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:35.001789Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.001800Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.001837Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.001845Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.020478Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.020526Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:35.020636Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:35.020646Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.020652Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:35.020654Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:35.020666Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:35.020670Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.020673Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.020672Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:35.020676Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:35.020679Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:35.058172Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.058199Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:35.058274Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=93 +2026-06-16T07:14:35.058281Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.058286Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:35.058287Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 +2026-06-16T07:14:35.058297Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:35.058299Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.058301Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.058324Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=93 +2026-06-16T07:14:35.058330Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=93 gossip_kind="update" gossip_payload_bytes=93 frame_bytes=97 neighbors_only=false +2026-06-16T07:14:35.058336Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=97 neighbors_only=false +2026-06-16T07:14:35.086147Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.086158Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.095422Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=true +2026-06-16T07:14:35.095441Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=117 +2026-06-16T07:14:35.095449Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.095453Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:35.095456Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 +2026-06-16T07:14:35.095490Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=117 gossip_kind="presence" gossip_payload_bytes=117 frame_bytes=121 neighbors_only=false +2026-06-16T07:14:35.095501Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=121 neighbors_only=false +2026-06-16T07:14:35.095536Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.095540Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.211160Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.211171Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.224395Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.224406Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.233324Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.233333Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.242644Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.242654Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.447978Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:35.448917Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=128 +2026-06-16T07:14:35.448925Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.448932Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.448934Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.448936Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:35.448938Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.448940Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.448959Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=128 +2026-06-16T07:14:35.448963Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 gossip_kind="update" gossip_payload_bytes=128 frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.448967Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.478629Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.478638Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.487751Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.487760Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.496671Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.496682Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.595656Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:35.596549Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=128 +2026-06-16T07:14:35.596558Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.596563Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.596564Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.596575Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:35.596577Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.596581Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.596612Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=128 +2026-06-16T07:14:35.596616Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 gossip_kind="update" gossip_payload_bytes=128 frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.596620Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.624404Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.624416Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.633414Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.633424Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.740786Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:35.741605Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=128 +2026-06-16T07:14:35.741613Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.741616Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.741619Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.741622Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:35.741624Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.741626Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.741639Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=128 +2026-06-16T07:14:35.741642Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 gossip_kind="update" gossip_payload_bytes=128 frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.741646Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.768959Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.768971Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.777935Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.777947Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.879092Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:35.879955Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:858: queued collaboration update publish from local Loro update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=128 +2026-06-16T07:14:35.879963Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:35.879966Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.879967Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 +2026-06-16T07:14:35.879972Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:693: applied local collaboration edit to Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 +2026-06-16T07:14:35.879974Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:698: local collaboration edits flushed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:35.879976Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.879986Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:153: collaboration update will be sent inline over gossip session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce update_bytes=128 +2026-06-16T07:14:35.879989Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" payload_bytes=128 gossip_kind="update" gossip_payload_bytes=128 frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.879992Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="update" frame_bytes=133 neighbors_only=false +2026-06-16T07:14:35.905981Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.905990Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:35.915380Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:35.915388Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.019601Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:36.019612Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.039337Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:36.039350Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.052968Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:36.052978Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.066773Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:36.066782Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.094528Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:199: skipping collaboration self-check tick session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce phase=Attached(Attachment { connectivity: Online, peers_present: 1 }) idle_for=214.551417ms +2026-06-16T07:14:36.095866Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:183: collaboration presence timer fired session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.095880Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:39: refreshing own collaboration presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce has_selection=false +2026-06-16T07:14:36.095890Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=81 +2026-06-16T07:14:36.095895Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:36.095898Z TRACE main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:75: collaboration connectivity considered online session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce peers_present=1 neighbors=1 +2026-06-16T07:14:36.095899Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=81 +2026-06-16T07:14:36.095901Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=81 +2026-06-16T07:14:36.095911Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:88: refreshing collaboration external carets session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce carets=1 +2026-06-16T07:14:36.095918Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=81 gossip_kind="presence" gossip_payload_bytes=81 frame_bytes=85 neighbors_only=false +2026-06-16T07:14:36.095924Z TRACE tokio-rt-worker ThreadId(06) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=85 neighbors_only=false +2026-06-16T07:14:36.095929Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:670: no local collaboration edits to flush session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=0 operation_count=0 +2026-06-16T07:14:36.095932Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.137198Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:830: collaboration local selection changed; refreshing presence session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.137226Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:672: flushing local collaboration edits into Loro session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce edit_count=1 operation_count=1 +2026-06-16T07:14:36.137231Z ERROR main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:690: applying local collaboration edit failed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce operation_count=1 error=paragraph id is not present in DocBinding +2026-06-16T07:14:36.137234Z ERROR main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:819: capturing local collaboration edit failed session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce error=paragraph id is not present in DocBinding +2026-06-16T07:14:36.137235Z WARN main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:602: detaching collaboration session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce reason=Fatal("capturing local collaboration edit failed: paragraph id is not present in DocBinding") phase=Attached(Attachment { connectivity: Online, peers_present: 1 }) +2026-06-16T07:14:36.137244Z TRACE main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:537: queued collaboration presence publish from local update session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=73 +2026-06-16T07:14:36.137247Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:117: queued collaboration presence publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce bytes=73 +2026-06-16T07:14:36.137249Z TRACE main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:194: remote collaboration patch flush skipped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce pending_patches=0 deferred=false +2026-06-16T07:14:36.137251Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:36.137255Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=73 +2026-06-16T07:14:36.137257Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=73 +2026-06-16T07:14:36.137264Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="publish" +2026-06-16T07:14:36.137266Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:162: publishing collaboration payload session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=73 +2026-06-16T07:14:36.137267Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:58: queueing collaboration gossip publish session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=73 +2026-06-16T07:14:36.137267Z TRACE flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:121: collaboration network command received command="leave_session" +2026-06-16T07:14:36.137268Z INFO flowstate-collab-net ThreadId(05) flowstate_collab::net::runtime: crates/flowstate-collab/src/net/runtime.rs:150: leaving collaboration network session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.137270Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:67: stopping collaboration gossip swarm session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.137272Z DEBUG flowstate-collab-net ThreadId(05) flowstate_collab::net::direct: crates/flowstate-collab/src/net/direct.rs:75: detached collaboration direct session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce removed=true blob_count=0 handler_removed=true attached_sessions=0 +2026-06-16T07:14:36.137273Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=73 gossip_kind="presence" gossip_payload_bytes=73 frame_bytes=77 neighbors_only=false +2026-06-16T07:14:36.137278Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=77 neighbors_only=false +2026-06-16T07:14:36.137280Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:129: broadcasting collaboration gossip frame session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" payload_bytes=73 gossip_kind="presence" gossip_payload_bytes=73 frame_bytes=77 neighbors_only=false +2026-06-16T07:14:36.137281Z TRACE tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:145: collaboration gossip frame broadcast complete session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce payload_kind="presence" frame_bytes=77 neighbors_only=false +2026-06-16T07:14:36.137283Z DEBUG tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:94: collaboration gossip swarm stop received session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.137284Z INFO tokio-rt-worker ThreadId(07) flowstate_collab::net::swarm: crates/flowstate-collab/src/net/swarm.rs:110: collaboration gossip swarm stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.142235Z INFO main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:651: collaboration session detached and cleaned up session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:36.211296Z TRACE main ThreadId(01) flowstate::collab::session::session_presence: crates/flowstate/src/collab/session_presence.rs:31: skipping own collaboration presence refresh because store is missing session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:37.338685Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:118: collaboration digest timer stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:14:46.097066Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:134: collaboration presence timer stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:15:06.095788Z DEBUG main ThreadId(01) flowstate::collab::session::session_timers: crates/flowstate/src/collab/session_timers.rs:150: collaboration self-check timer stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:15:09.087372Z INFO main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:229: leaving collaboration session for panel panel_id=84e8e2ce-bf18-4ba1-9eaf-22b68dab754e session_id=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:15:09.087394Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:598: collaboration session already detached session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce reason=UserLeft +2026-06-16T07:15:09.087401Z DEBUG main ThreadId(01) flowstate::collab::manager: crates/flowstate/src/collab/manager.rs:314: unregistered collaboration session session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce removed=true open_sessions=0 +2026-06-16T07:15:09.088957Z DEBUG main ThreadId(01) flowstate::collab::session::session_io: crates/flowstate/src/collab/session_io.rs:89: collaboration direct request pump stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce +2026-06-16T07:15:09.088989Z DEBUG main ThreadId(01) flowstate::collab::session: crates/flowstate/src/collab/session.rs:906: collaboration undo request pump stopped session=14a5450ad3672578fecf8bf550e755282689527dde18d0268fd642c4ad9990ce diff --git a/flowstate_loro_review_findings_at_9cb8ad8.csv b/flowstate_loro_review_findings_at_9cb8ad8.csv new file mode 100644 index 00000000..8c3cc1e5 --- /dev/null +++ b/flowstate_loro_review_findings_at_9cb8ad8.csv @@ -0,0 +1,171 @@ +ID,Severity,Status,Area,File,Lines,Finding,Impact,Fix direction,Verification,Architecture relation,Permalink,Latest review status,Latest review note,Reviewed head +FS-001,Blocker,Confirmed-static,Projection / inline objects,crates/flowstate-document/src/loro_projection.rs,180-268,Object placeholders are always projected as standalone blocks.,Text before and after U+FFFC is split into separate paragraph blocks. Inline equations/images cannot retain inline placement even though the schema supports inline display.,"Represent inline objects inside paragraph projection spans, or explicitly remove inline-object support from the schema and UI. Do not force every placeholder through InputBlock.","Create `A[inline equation]B`, reopen, export, and concurrently edit around both sides.",Conflicts with the plan's requirement that inline and block equations use the same placeholder and are rendered according to metadata.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L180-L268,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-002,High,Confirmed-static,Projection / corruption,crates/flowstate-document/src/loro_projection.rs,234-257,A placeholder with no resolvable block record silently disappears.,"Canonical text can contain a structural anchor that is invisible in the editor, search, and export. A later edit may unknowingly delete or relocate it.",Project an explicit corrupt/missing-object block and schedule a Loro repair; never silently drop the anchor.,"Delete a block-map entry while leaving U+FFFC, then reopen.",The plan requires malformed state to be repaired before further sharing.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L234-L257,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-003,High,Confirmed-static,Projection / corruption,crates/flowstate-document/src/loro_projection.rs,271-294,Malformed or unresolved object anchors are silently skipped.,"Blocks with missing cursor bytes, undecodable cursors, or failed resolution become unreachable without a visible error.","Return structured projection diagnostics, render a recovery placeholder, and schedule canonical repair/quarantine.",Corrupt each anchor field independently and reopen.,Violates deterministic rendering from canonical state and repair expectations.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L271-L294,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-004,High,Confirmed-static,Projection / collisions,crates/flowstate-document/src/loro_projection.rs,271-294,Multiple object blocks at the same resolved cursor overwrite each other in a BTreeMap.,"Only the last block survives projection, causing invisible durable content.","Store a vector per position and define deterministic tie ordering, preferably by CRDT anchor ordering plus durable block ID.",Insert two concurrent objects at one cursor from separate replicas.,Object ordering must be canonical and deterministic.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L271-L294,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-005,High,Confirmed-static,Projection / identity,crates/flowstate-document/src/loro_projection.rs,554-573,Projection fabricates paragraph and block IDs from the current block index when metadata is missing.,"The fallback IDs shift when structure changes and can be used by later commands, causing commands to target the wrong paragraph/block.",Treat missing durable identity as malformed canonical state. Schedule a Loro repair that creates durable IDs before accepting commands.,"Remove paragraph metadata, reopen, insert an object before it, and compare IDs.",All durable IDs must be stored in Loro; projection-generated IDs must not become canonical.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L554-L573,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-006,Medium,Confirmed-static,Projection / assets,crates/flowstate-document/src/loro_projection.rs,306-319,Invalid image asset IDs are coerced to AssetId(0).,"Different malformed references alias the same synthetic ID, hiding corruption and potentially displaying the wrong bytes.",Reject malformed IDs or project an explicit invalid-asset placeholder carrying the raw value.,Set `asset_id` to non-numeric text and reopen.,Asset identity must be canonical and integrity-safe.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L306-L319,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-007,High,Confirmed-static,Projection / captions,crates/flowstate-document/src/loro_projection.rs,477-492,Only the first image-caption paragraph is projected.,Additional caption paragraphs remain in Loro but are invisible and can be lost when the caption is edited through the one-paragraph UI model.,"Change the image projection type to hold a paragraph flow, not one InputParagraph.","Create a two-paragraph caption, reopen, edit, and compare Loro flow.",Caption is an independent paragraph-bearing text flow.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L477-L492,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-008,Medium,Confirmed-static,Projection / plain flows,crates/flowstate-document/src/loro_projection.rs,477-484,"`trim_start_matches('\n')` removes all leading newlines, not only the sentinel.","Legitimate leading blank lines in equation, alt-text, header, footer, or other flows are collapsed.",Remove exactly one sentinel code point when the flow kind requires it.,Create a flow with sentinel plus two leading blank lines and round-trip it.,Projection must deterministically preserve canonical flow contents.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L477-L484,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-009,High,Confirmed-static,Projection / tables,crates/flowstate-document/src/loro_projection.rs,398-426,Images and equations inside table-cell flows are discarded.,"Canonical nested objects can exist but vanish from the table projection, search/export view, and editor.",Extend TableCellBlock to represent all supported object kinds or prevent their insertion canonically with explicit validation.,"Insert an image and equation into a cell flow, reopen, and export.",Cell flows are continuous flows and the object-placeholder strategy applies to supporting flows.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L398-L426,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-010,High,Likely,Projection / tables,crates/flowstate-document/src/loro_projection.rs,340-395,Ragged or overlapping table topology is accepted without a normalization/repair phase.,"Concurrent row/column/cell edits can produce missing cells, duplicate coordinates, invalid spans, or partial rows that render inconsistently.",Add a deterministic table-grid validator and canonical repair transaction; project explicit conflict placeholders until repaired.,"Fuzz concurrent row/column insertion, deletion, span edits, and cell removal.","Tables must remain structured, deterministic CRDT objects.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L340-L395,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-011,Medium,Lead,Projection / sections,crates/flowstate-document/src/loro_projection.rs,130-177,Sections whose start paragraph cannot be resolved are sorted to the end instead of repaired or rejected.,Dangling section metadata can appear attached to unrelated content.,Validate all paragraph references and surface/schedule repair for dangling section anchors.,Delete a section's referenced paragraph concurrently and reopen.,Section boundaries must be CRDT-stable positions.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L130-L177,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-012,High,Confirmed-static,Editor / sections,crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs,19-23,Editor construction immediately rebuilds sections from heading styles.,Canonical Loro page sections loaded by the projector can be overwritten by a derived outline before first render.,Separate page sections from heading outline. Never call `rebuild_document_sections` over canonical projected sections.,Open a document with a non-heading section boundary and inspect the editor projection.,Page sections and heading outline are distinct concepts in the architecture.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs#L19-L23,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-013,High,Confirmed-static,Editor / projection patches,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,92-145,Every structural projection patch rebuilds the document from blocks.,The rebuild discards section/page metadata and resets unrelated projection state and caches.,"Apply structural patches to a frontier-scoped projection structure that preserves sections, IDs, assets, and page metadata; use full canonical reproject only as fallback.",Apply a remote object insertion to a document with section page settings.,Projection caches are derived but must faithfully represent the same canonical frontier.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L92-L145,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-014,High,Confirmed-static,Schema integration / sections,crates/flowstate-document/src/loro_schema.rs,43-220,Section page attributes are defined canonically but are not consistently preserved through editor mutation and export.,The data exists in Loro yet becomes functionally dead or is lost in projection rebuilds.,"Add end-to-end projection, editor, DOCX, PDF, and package round-trip coverage for every section field.","Round-trip non-default page size, margins, columns, orientation, numbering, header, and footer.",The plan requires sections and page structure to operate from Loro state.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L43-L220,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-015,Medium,Confirmed-static,Schema / sections,crates/flowstate-document/src/loro_schema.rs,451-482,Clearing a header or footer does not remove the old flow-ID attribute.,Setting `None` leaves stale canonical references active.,Delete the map key when the new optional value is None; decide retention policy for unreachable flows separately.,"Set header/footer, clear them, and read attributes back.",Canonical metadata must reflect current state exactly.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L451-L482,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-016,Medium,Confirmed-static,Schema / validation,crates/flowstate-document/src/loro_schema.rs,463-510,Section numeric attributes are not range-validated.,"Negative/zero dimensions, margins, columns, and numbering starts can enter canonical state and break render/export.",Validate and normalize at the semantic command boundary; retain raw malformed values only for diagnostics.,Inject negative and extreme values through Loro import.,Runtime owns schema validation.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L463-L510,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-017,High,Confirmed-static,Projection / paragraph-object semantics,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,210-247,Incremental paragraph projection ignores object placeholders while full projection splits around them.,Full rebuild and incremental update can produce different paragraph indexes and text around the same canonical frontier.,"Use one shared flow parser for full and incremental projection, including inline/object spans.",Edit text adjacent to an object and compare incremental result with a forced full rebuild.,Deterministic rendering from canonical state requires equivalent projection paths.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L210-L247,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-018,Medium,Lead,Projection / object ordering,crates/flowstate-document/src/loro_projection.rs,31-45,`object_input_blocks_from_loro` sorts objects by BlockId rather than body anchor.,"Any caller treating this as document order receives registry order, not canonical flow order.",Rename it to registry-order explicitly or return objects in body-anchor order with reachability filtering.,Trace all call sites and compare order with a document containing moved objects.,Body flow is canonical for body-object ordering.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_projection.rs#L31-L45,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-019,Medium,Confirmed-static,Diagnostics,crates/flowstate-collab/src/self_check.rs,10-35,"Projection drift hash ignores sections, IDs, frontier, assets, and page metadata.",The self-check can report equality while meaningful projected state differs.,"Hash all user-visible and command-targeting projection fields, or use a canonical serialized diagnostic projection.",Modify only section/page/ID/asset state and compare hashes.,Projection integrity checks should cover all derived state.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/self_check.rs#L10-L35,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-020,Low,Confirmed-static,Diagnostics / performance,crates/flowstate-collab/src/self_check.rs,28-31,Object drift hashing formats entire blocks with Debug.,Large tables and nested structures cause avoidable allocation and unstable diagnostic hashing.,Implement explicit stable hashing for each block type.,Benchmark hash on large nested tables.,Observability should not create large hidden work.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/self_check.rs#L28-L31,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-021,Blocker,Confirmed-static,CRDT runtime / transactions,crates/flowstate-collab/src/crdt_runtime.rs,942-989,A semantic command batch is committed command-by-command and can fail halfway.,"Earlier commands are already canonical, persisted, and emitted before a later command returns an error. The caller sees failure, undo grouping may be missing, and peers can observe intermediate state.","Pre-resolve and validate the entire batch against one frontier, then apply it in one Loro transaction/change and one UndoManager group. Roll back or fork a staged doc on failure.","Submit a valid insert followed by an invalid style/ID command and inspect Loro, package, UI, and outbound updates.",The plan requires grouped semantic commands to become grouped Loro changes.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L942-L989,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-022,High,Confirmed-static,Editor command generation,crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs,193-276,Normal edits routinely generate multi-command batches.,"The partial-commit defect is reachable through ordinary replacements, formatting, and paste rather than only malformed API calls.",Fix runtime atomicity; do not rely on command producers to emit one command.,"Exercise replace-selection, paste, and style changes with an injected failure after the first command.",Semantic undo units must be atomic.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs#L193-L276,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-023,High,Confirmed-static,Workspace / command queues,crates/flowstate/src/workspace/workspace/documents.rs,1726-1753,Queued edit batches are flattened across frontiers with only a debug assertion.,Release builds can combine commands constructed from different projections into one request.,Reject mixed-frontier queues in all builds and rebase each queued command through canonical cursors or resnapshot before submitting.,Queue a second edit after a remote update before the first local flush completes.,Projection coordinates are valid only at their originating frontier.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1726-L1753,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-024,High,Likely,CRDT actor / coalescing,crates/flowstate-collab/src/crdt_runtime_actor.rs,360-470,Actor coalescing can merge distinct submitters that happen to share a frontier.,"Only one response/event stream may represent multiple requests, and coordinate dependencies between requests are not explicit.",Attach monotonically increasing edit-request IDs; preserve one response per request or prove a single serialized producer invariant.,Concurrent-submit two batches from save/export and live flush.,"One runtime owner is correct, but request semantics must remain explicit.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime_actor.rs#L360-L470,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-025,Critical,Confirmed-static,CRDT runtime / authorship,crates/flowstate-collab/src/crdt_runtime.rs,639-649,`set_author_identity` commits canonical Loro state without persistence or LocalUpdate emission.,"The frontier advances outside the package update chain and outside collaboration. Later edits may be stale, and durable user/replica metadata can disappear after reopen.","Route identity changes through the same local-change pipeline as document mutations: capture from frontier/VV, append update, emit LocalUpdate, refresh projection if needed.","Join a session, set identity, close before editing, reopen, and inspect users/replicas.",All canonical Loro changes must be persisted and synchronized.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L639-L649,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-026,High,Confirmed-static,Collaboration startup,crates/flowstate/src/collab/session.rs,650-705,Author registration is launched separately from the first editor flush.,A first edit can race the metadata commit and be rejected as stale or be acknowledged against an unexpected frontier.,Complete runtime initialization and identity persistence before enabling editor capture.,Join and type immediately under artificial actor delay.,The editor projection frontier must match the live runtime before command construction.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L650-L705,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-027,High,Confirmed-static,CRDT runtime / durability,crates/flowstate-collab/src/crdt_runtime.rs,1556-1578,Persistence occurs after the Loro mutation is committed.,A disk/journal failure leaves canonical in-memory state changed while the method returns an error and may emit no projection/network events.,"Stage the update and package write before acknowledging success, or track an explicit durable-dirty state and still return canonical events so the UI cannot diverge.",Inject append failure after a valid command.,Canonical mutation and durable append need a defined failure contract.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L1556-L1578,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-028,High,Confirmed-static,Package state / durability,crates/flowstate-document/src/package.rs,371-405,`append_update_segment` mutates the in-memory package before validation completes.,A validation error leaves the package object partially advanced.,"Build a cloned/staged package, validate it, then replace `self` only after success.",Inject invalid frontier/checksum state and compare package before/after error.,Crash-safe package coordination must be transactional.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L371-L405,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-029,High,Confirmed-static,Package state / assets,crates/flowstate-document/src/package.rs,322-326,`replace_assets_from_document` mutates package assets before all fallible validation work.,An error leaves asset indexes and bytes partially changed in memory.,Stage and validate asset replacement before committing to `self`.,Inject malformed asset metadata or index failure.,Package updates must be all-or-nothing.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L322-L326,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-030,High,Confirmed-static,Save / checkpoint,crates/flowstate-collab/src/crdt_runtime.rs,1469-1537,Checkpointing performs many fallible canonical/package mutations without a transaction boundary.,"Failure after revision insertion, cache rebuild, compaction, or path change can leave an internally inconsistent runtime and misleading save status.",Construct a staged checkpoint package and write atomically; only then swap runtime package state and mark saved.,Inject a failure at each step in sequence.,Explicit save is a revision boundary and must be crash-safe.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L1469-L1537,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-031,High,Likely,Journal continuity,crates/flowstate-collab/src/crdt_runtime.rs,1556-1578,A failed append can be followed by a later append from advanced in-memory state.,"The disk journal can receive a segment whose `from_frontier` is not present on disk, creating a replay gap.",Track last durable frontier separately and refuse further appends until the missing segment is durably written or a full atomic snapshot replaces the file.,"Fail one append, allow a later edit to succeed, then reopen from disk.",Update segments must form a complete replay chain.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L1556-L1578,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-032,High,Confirmed-static,Package compaction,crates/flowstate-document/src/package.rs,603-637,Compaction clears update segments and prunes snapshots before all cache rebuild work succeeds.,A later error leaves the in-memory package partially compacted.,"Perform compaction on a clone and commit the clone only after snapshot, caches, indexes, and validation all succeed.",Inject search/projection cache rebuild failures.,Compaction must preserve named revisions and latest state atomically.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L603-L637,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-033,High,Confirmed-static,Tables / concurrency,crates/flowstate-collab/src/crdt_runtime.rs,2860-3260,Generic table replacement clears and repopulates row/column/cell containers.,Normal table edits lose granular CRDT history and turn concurrent structural edits into broad conflicts.,Implement dedicated row/column/cell commands over durable IDs and mutate only affected containers.,Edit different cells/rows concurrently while another peer resizes or moves a column.,Tables must be structurally CRDT-native and not rewritten wholesale.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L2860-L3260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-034,High,Confirmed-static,Nested text / concurrency,crates/flowstate-collab/src/crdt_runtime.rs,3260-3620,"Alt text, captions, equation source, and table-cell edits use whole-flow replacement paths.",Concurrent character-level edits can overwrite each other instead of merging naturally.,Expose cursor/range text commands for every nested flow and apply LoroText insert/delete/mark operations.,Concurrent-edit distinct positions in each nested flow.,These fields are required to be independent CRDT text flows.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L3260-L3620,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-035,High,Confirmed-static,Tables / identity,crates/flowstate-collab/src/crdt_runtime.rs,2300-2860,"Table editor commands address rows, columns, and cells by numeric index.",Concurrent reorder/insert/delete can make a stale command target the wrong entity.,"Carry durable RowId, ColumnId, CellId and a base frontier/cursor in every semantic command.",Reorder a column remotely while a local cell edit is queued.,Rows/columns/cells require durable Loro-native identity.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L2300-L2860,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-036,High,Likely,Text deletion / objects,crates/flowstate-collab/src/crdt_runtime.rs,1026-1058,The low-level DeleteRange path can delete sentinel/newline/object characters without first pruning or repairing related metadata.,"It can leave orphaned block maps, paragraph maps, sections, or an invalid sentinel.",Reject structural deletion through generic text commands; use semantic join/delete-object commands that update all metadata in one transaction.,"Delete across the sentinel, newline, and U+FFFC through direct runtime API and editor-generated ranges.",Structural characters are not ordinary user text.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L1026-L1058,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-037,High,Lead,Paragraph replacement / objects,crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs,193-276,ReplaceParagraphSpan is derived from paragraph-only spans and may cross object boundaries represented separately in the body flow.,A replacement around object-adjacent paragraphs may remove or relocate structural placeholders unintentionally.,"Build semantic ranges from canonical flow cursors including object affinity, not reconstructed paragraph snapshots.",Paste/replace selections spanning text-object-text.,Canonical command positions should use Loro cursors wherever possible.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs#L193-L276,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-038,Medium,Confirmed-static,Runtime indexes,crates/flowstate-collab/src/crdt_runtime.rs,368-436,Incremental index maintenance only shifts paragraph and placeholder positions.,"Style, search, table, section, asset, and durable-ID indexes can remain stale after patches that do not trigger a rebuild.",Define per-patch updates for every index or conservatively rebuild affected indexes.,Compare every index with a freshly rebuilt runtime index after random patch sequences.,The plan requires explicit projection indexes with reliable invalidation.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L368-L436,Partially fixed; remains open,"Text-length patches now update search_unit_spans as well as paragraph/object positions. The runtime still ignores ParagraphRuns, ParagraphStyle, ReplaceObjectBlock, and AssetArrived for the affected auxiliary indexes, so style/table/asset/identity indexes can remain stale.",9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-039,Medium,Confirmed-static,Runtime actor / memory,crates/flowstate-collab/src/crdt_runtime_actor.rs,70-110,The CRDT actor command channel is unbounded.,"A blocked runtime can accumulate unlimited edits, imports, assets, saves, and exports.",Use bounded channels with backpressure and prioritize shutdown/import/control messages.,Pause actor processing and flood commands.,"Dedicated runtime ownership should isolate work, not create unbounded queues.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime_actor.rs#L70-L110,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-040,Medium,Lead,Loro subscriptions,crates/flowstate-collab/src/crdt_runtime.rs,567-604,Subscription summaries and local update byte vectors are unbounded between drains.,Canonical mutations outside ordinary command drains can grow buffers indefinitely.,"Bound buffers, coalesce summaries by frontier/container, and log dropped/coalesced counts.",Generate metadata commits without calling the normal event drain.,Permanent subscriptions need bounded processing.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L567-L604,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-041,Medium,Confirmed-static,Undo input,crates/flowstate-collab/src/crdt_runtime_actor.rs,500-545,Undo/redo forwarding uses non-blocking bounded sends that can drop requests.,Rapid user input can silently lose undo or redo actions.,Disable UI while one undo is in flight or enqueue with an explicit acknowledgement/error.,Hold the actor and press undo repeatedly.,Undo is user-visible state and must not be silently dropped.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime_actor.rs#L500-L545,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-042,Positive,Implemented,Runtime ownership,crates/flowstate-collab/src/crdt_runtime.rs,483-620,"The runtime owns LoroDoc, UndoManager, permanent subscriptions, package state, and projection.",This is the correct core ownership direction.,Keep this boundary while fixing transactionality and event routing.,No defect; retain architecture.,Matches the dedicated CRDT runtime requirement.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L483-L620,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-043,Positive,Implemented,Selection / cursors,crates/gpui-flowtext/src/document/position.rs,17-71,Selection affinity and visual gravity are explicit stored fields.,Caret intent can survive collaboration and undo instead of being guessed from direction.,Add full bidi behavior and more boundary tests.,No defect; test concurrent same-position insertion and wrapping.,Matches the selection-affinity requirement.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/document/position.rs#L17-L71,Implementation strengthened,Presence and undo now construct stable boundary cursors from the preceding/following text element; remote cursor resolution uses Event→Unicode conversion and preserves visual gravity. Two focused tests were added.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-044,Medium,Lead,Selection / bidi,crates/gpui-flowtext/src/document/position.rs,63-65,RTL/bidi gravity is explicitly simplified to neutral behavior.,Caret restoration and collaborative selection can be visually wrong in bidirectional text.,Integrate bidi-resolved visual positions into affinity/gravity mapping.,Add Arabic/Hebrew mixed-line collaboration and undo tests.,The target selection model calls out bidi behavior.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/document/position.rs#L63-L65,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-045,Critical,Confirmed-static,Workspace / event routing,crates/flowstate/src/workspace/workspace/documents.rs,360-455,"Native save, export, and undo hooks discard RuntimeEvents returned by command application.","LocalUpdate, repair patches, selection restoration, and other events can vanish depending on which UI action flushed pending edits.","Centralize all runtime calls through one event dispatcher that always applies projection events, publishes LocalUpdate, and updates session state.","Type without normal flush, then immediately save/export/undo in a live session.",There must be one local mutation/event path.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L360-L455,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-046,High,Confirmed-static,Save / collaboration,crates/flowstate/src/workspace/workspace/documents.rs,360-455,Checkpoint LocalUpdate events are not directly forwarded by save hooks.,"Peers learn about revision/checkpoint metadata only through later digest recovery, and may miss it if the session closes.",Publish every LocalUpdate returned by checkpoint before reporting save success.,Save and immediately close the host while a peer remains connected.,Canonical revision metadata must synchronize like other Loro updates.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L360-L455,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-047,Critical,Confirmed-static,Collaboration / failure handling,crates/flowstate/src/collab/session.rs,880-970,Any local runtime error during flush detaches the whole collaboration session.,"A disk error, malformed command, or partial batch failure destroys the active session and can strand optimistic UI state.",Classify errors: stale -> rebase; persistence -> remain connected and show durable-dirty; bad local command -> rollback/reproject; protocol lineage -> isolate peer.,Inject each error class during normal typing.,Local persistence and network liveness must not share one fatal path.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L880-L970,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-048,High,Confirmed-static,Collaboration / persistence,crates/flowstate/src/collab/session.rs,880-970,Local package-write failures are treated as collaboration-fatal.,Networking is lost even though peers and in-memory CRDT may still be healthy.,Track unsaved state and retry persistence independently from the session.,Make the package path read-only during a session.,Runtime should isolate CRDT work from filesystem failures.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L880-L970,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-049,High,Likely,Collaboration / detach,crates/flowstate/src/collab/session.rs,930-1040,Detach initiates an asynchronous final flush while removing runtime/session hooks immediately.,Final optimistic edits can complete after the session is gone and never be published or applied.,Make detach await an acknowledged flush or explicitly cancel and canonical-reproject before teardown.,Type and instantly leave/close under delayed runtime execution.,No user edit should be silently abandoned during lifecycle transition.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L930-L1040,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-050,High,Confirmed-static,Editor / error rollback,crates/flowstate/src/collab/session.rs,880-970,Non-stale runtime errors complete the in-flight marker without restoring the editor from canonical projection.,Optimistic UI may continue showing content that was never accepted canonically.,"On any rejected batch, replace/rebase from runtime projection and restore transformed selection.",Inject invalid command after optimistic mutation.,UI projection is derived and must converge to runtime state.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L880-L970,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-051,High,Likely,Editor / acknowledgement,crates/flowstate/src/workspace/workspace/documents.rs,1756-1799,The acknowledge-without-replay path assumes local optimistic projection exactly equals canonical runtime output.,"Canonical repair, normalization, or metadata-driven visible changes in the same batch can be skipped while the frontier advances.",Have the runtime label events as pure echo versus corrective projection; acknowledge only pure echoes.,Trigger a command that also repairs malformed paragraph/object metadata.,Derived projection must reflect every canonical repair.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1756-L1799,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-052,High,Confirmed-static,Workspace / event routing,crates/flowstate/src/workspace/workspace/documents.rs,1756-1799,Acknowledge-without-replay returns early and ignores all other event kinds.,"SelectionRestored, projection repairs, revision events, or other future event types in the batch are lost.",Filter only the local echo patch; process the remainder through the common dispatcher.,Return a batch containing LocalUpdate plus SelectionRestored/ProjectionUpdated.,Runtime event handling must be complete and centralized.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1756-L1799,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-053,High,Confirmed-static,Recovery,crates/gpui-flowtext/src/rich_text/editor/recovery.rs,144-247,Runtime recovery snapshots can run without first flushing pending optimistic semantic edits.,"The recovery file can omit the newest unsaved edits, then record that generation as recovered.","Recovery hook must atomically submit pending edits, await canonical acknowledgement, and snapshot that exact frontier.","Type, block the normal runtime flush, wait for recovery, then crash and restore.",Recovery must serialize canonical Loro state at the user's latest accepted edit.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/recovery.rs#L144-L247,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-054,Medium,Confirmed-static,Assets / dirty state,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,5-20,Receiving out-of-band asset bytes marks the document as edited.,"A cache/availability event causes dirty indicators, autosave, recovery writes, and close prompts even when canonical metadata did not change.",Separate asset availability/cache generation from document edit generation.,"Open a document with a missing image, receive bytes, and inspect dirty state.",Asset bytes are outside Loro and availability is not a document-history edit.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L5-L20,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-055,Medium,Confirmed-static,Assets / undo,crates/flowstate/src/collab/session.rs,730-790,Applying synchronized assets clears editor undo/redo stacks.,A non-document cache arrival destroys user history.,Do not clear canonical Loro undo for asset availability. Invalidate only affected image layout/cache.,"Make edits, receive image bytes, then undo.","Undo is based on Loro document changes, not asset-transfer completion.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L730-L790,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-056,High,Confirmed-static,Editor / structural patches,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,92-140,Remote structural patches rebuild the whole visible document while local optimistic edits may be queued.,Queued local text/object state can be overwritten or selections reset.,"Rebase queued semantic edits over the new canonical frontier, or serialize remote application against speculative overlays.",Delay local runtime acknowledgement and apply a remote object insertion.,UI receives derived diffs but must preserve/rebase local intent.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L92-L140,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-057,Medium,Confirmed-static,Editor / object state,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,104-157,Broad structural changes clear active image/table/equation editing state.,Unrelated remote structure changes interrupt local editing.,Transform selected object IDs through the patch and clear only if the selected object was deleted/replaced.,Edit a table cell while a remote user inserts an unrelated image.,Selection should be durable-ID/cursor based.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L104-L157,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-058,Medium,Lead,Editor / deferred apply,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,51-57,"Projection apply is deferred during drag, resize, selection, and IME, but queue limits/order need explicit guarantees.",A long interaction under remote activity may accumulate stale patches or apply them after local coordinates changed.,Use a bounded frontier-aware queue and collapse to one full snapshot when the queue is superseded.,Hold IME composition/drag while flooding remote updates.,Projection batching must be frontier-scoped.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L51-L57,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-059,Medium,Confirmed-static,Editor lifecycle,crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs,330-352,Disabling runtime capture resets the in-flight count without cancelling callbacks or changing an epoch.,A late callback can mutate an editor after route/session transition.,Attach a capture epoch/token to every request and ignore late completions from older epochs.,Disable capture while a delayed save/edit request is in flight.,Runtime lifecycle needs explicit ownership epochs.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs#L330-L352,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-060,Medium,Lead,Undo transition,crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs,369-377,The editor retains a separate local undo stack whenever command capture is temporarily disabled.,A lifecycle gap can create edits in the wrong undo system.,"Once a Loro runtime is attached, disable projection-native document history permanently; use a speculative overlay only.",Edit during attach/detach and startup transitions.,Undo must be Loro-native for all documents.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs#L369-L377,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-061,High,Likely,Autosave / event routing,crates/flowstate/src/workspace/workspace/documents.rs,1450-1464,Autosave directly invokes editor save while live edit flushing is independently scheduled.,"Even with actor serialization, pending commands and returned events can be consumed by the wrong path and dropped.",Make autosave request a checkpoint through the same collaboration-aware flush/event dispatcher.,Trigger autosave while a local batch and remote import are in flight.,One canonical command/persistence/sync pipeline is required.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1450-L1464,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-062,Medium,Confirmed-static,Close / save,crates/flowstate/src/workspace/workspace/documents.rs,1604-1670,Save-on-close does not explicitly forward checkpoint events before session shutdown.,The file may save locally while peers never receive final revision/edit metadata.,Await common runtime event dispatch and network enqueue before closing the session.,Close a dirty collaborative document and inspect peer/frontier state.,Save and sync are expressions of the same canonical history.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1604-L1670,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-063,Medium,Confirmed-static,Editor / export lifecycle,crates/gpui-flowtext/src/rich_text/editor/send_export.rs,31-148,Successful save/export replaces the entire editor projection.,This can reset transient local UI state and force unnecessary full layout work even when the projection is only an acknowledged echo.,Acknowledge the frontier and apply only corrective diffs; reserve full replacement for actual divergence.,Export a large document while editing and inspect selection/collapse/layout state.,"Full rebuild is exceptional, not the ordinary path.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/send_export.rs#L31-L148,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-064,High,Confirmed-static,Collaboration / buffering,crates/flowstate/src/collab/session_io.rs,20-45,Remote updates received before runtime attachment are stored in an unbounded VecDeque.,A peer can exhaust memory before document attachment completes.,Bound by message count and total bytes; spill/reject and trigger one version-vector pull after attach.,Delay attach and flood unique updates.,P2P input queues must be bounded.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session_io.rs#L20-L45,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-065,High,Confirmed-static,Anti-entropy / robustness,crates/flowstate/src/collab/session.rs,380-450,Malformed digest/version-vector data propagates as a session-fatal error.,One bad peer packet can detach a healthy collaborative session.,"Drop/quarantine the sender, record telemetry, and continue the session; only local invariant corruption should be fatal.",Send invalid VV bytes from one peer in a three-peer session.,Import/anti-entropy must be resilient to peer input.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session.rs#L380-L450,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-066,High,Confirmed-static,Remote import / robustness,crates/flowstate/src/collab/session_io.rs,47-88,Any remote Loro import error is escalated to session failure.,A corrupt or incompatible update from one peer disconnects everyone locally.,"Reject the update, isolate the sender, and request a verified snapshot/update from another peer.",Send malformed update bytes while another peer remains healthy.,Remote updates should be accepted/rejected without destroying runtime liveness.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session_io.rs#L47-L88,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-067,High,Likely,Anti-entropy / lineage,crates/flowstate-collab/src/net/anti_entropy.rs,90-121,Lineage mismatch is represented as a global GapAction rather than a peer-scoped quarantine policy.,A wrong-session digest can become a session-wide failure in the app layer.,Treat mismatch as invalid input from that endpoint; never mutate or detach the local session.,Inject a digest with another SessionId.,"Document lineage validation is required, but peer faults should remain isolated.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/anti_entropy.rs#L90-L121,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-068,Critical,Confirmed-static,Direct protocol / memory safety,crates/flowstate-collab/src/net/direct.rs,300-390,Direct payload reads trust a peer-declared u64 length and allocate before semantic validation.,A remote endpoint can request an arbitrarily large allocation and terminate the process.,Enforce protocol-specific maximum lengths before allocation; stream large payloads to bounded storage and use timeouts.,Send a length prefix near u64::MAX without a body.,Network input must be bounded before allocation.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/direct.rs#L300-L390,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-069,High,Confirmed-static,Direct protocol / liveness,crates/flowstate-collab/src/net/direct.rs,90-180,A request handler can wait indefinitely for the application/runtime response while holding a serving permit.,Slow or wedged local handling lets remote clients exhaust service capacity.,Add per-request deadlines and cancellation; release permits before long app work when possible.,Never answer a direct request and open four more.,Runtime/network isolation requires bounded waits.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/direct.rs#L90-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-070,Medium,Confirmed-static,Direct protocol / fairness,crates/flowstate-collab/src/net/direct.rs,40-90,Only a small global semaphore protects all direct serving.,A few slow requests in one session can starve all other sessions and message types.,Use per-peer/per-session quotas plus a larger global safety cap.,Open slow requests from one peer and test another session.,P2P resilience should avoid cross-session head-of-line blocking.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/direct.rs#L40-L90,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-071,High,Confirmed-static,Gossip / liveness,crates/flowstate-collab/src/net/swarm.rs,180-300,A gossip publish error exits the entire swarm task.,Transient oversize/backpressure/network failures kill collaboration instead of degrading one message.,"Classify publish errors, retry/backoff transient failures, drop only the bad message, and keep the swarm loop alive.",Force one publish failure and observe subsequent traffic.,Periodic digest is fallback liveness; the transport loop must survive errors.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/swarm.rs#L180-L300,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-072,High,Confirmed-static,Presence / transport,crates/flowstate-collab/src/proto_gossip.rs,1-120,Presence uses inline gossip payloads with a small limit.,Long display names or encoded presence state can exceed the cap; combined with fatal publish handling this kills the swarm.,"Bound/sanitize names and selection payloads, or use a separate capped presence encoding with graceful drop.",Set a very long display name and publish presence.,Presence is ephemeral and must never endanger document synchronization.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/proto_gossip.rs#L1-L120,Partially mitigated; remains open,"Presence snapshots now publish only the local peer state instead of encode_all, and external-caret work is debounced. Individual presence payloads, display names, the inline gossip limit, and fatal swarm publish behavior remain unchanged.",9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-073,Medium,Lead,Anti-entropy / size,crates/flowstate-collab/src/proto_gossip.rs,1-160,Version-vector digest size is not clearly bounded against transport limits.,Long-lived/high-replica documents may produce oversized digest messages.,Define maximum digest size and a fallback direct digest/snapshot negotiation.,Create thousands of replica entries and encode a digest.,Anti-entropy must scale with durable history.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/proto_gossip.rs#L1-L160,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-074,High,Confirmed-static,Network runtime / memory,crates/flowstate-collab/src/net/runtime.rs,40-160,Network command and event channels are unbounded.,"A blocked UI or slow network task can accumulate unlimited updates, presence events, blob requests, and status messages.","Use bounded channels, coalesce presence/digests, and apply backpressure to document-update producers.",Pause event consumption and flood updates.,Networking should not create unbounded process memory.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/runtime.rs#L40-L160,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-075,High,Confirmed-static,Blob outbox,crates/flowstate-collab/src/net/blobs.rs,1-180,A single blob larger than the outbox byte cap is inserted and then immediately evicted.,Peers receive an announcement for content the sender can no longer serve.,Reject oversize blobs before announcing or store them in a persistent/streaming content-addressed store.,Publish an update just above the byte cap.,Large updates/assets need reliable out-of-band availability.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/blobs.rs#L1-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-076,Medium,Likely,Blob transfer / retention,crates/flowstate-collab/src/net/blobs.rs,80-220,Rapid blob churn can evict content before slow peers request it.,Peers stall until periodic anti-entropy reconstructs another route.,"Retain until acknowledgement/expiry, pin latest document updates, or serve from package journal/content store.",Throttle a peer and publish many blobs.,Asset/update availability is part of sync status.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/blobs.rs#L80-L220,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-077,High,Confirmed-static,Anti-entropy / liveness,crates/flowstate-collab/src/net/anti_entropy.rs,27-136,One global `pull_in_flight` flag blocks pulls from all peers.,A hung pull prevents recovery from healthier peers indefinitely.,"Track pulls by peer/range, add deadlines, and allow fallback to another candidate.","Start a pull from an unresponsive peer, then advertise missing ops from another.",Immediate anti-entropy must remain live under peer failure.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/anti_entropy.rs#L27-L136,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-078,Medium,Lead,Anti-entropy / peer choice,crates/flowstate-collab/src/net/anti_entropy.rs,79-88,Lag recovery uses one fallback peer and has no immediate alternate selection in this state object.,Recovery waits on a poor candidate even when other peers are available.,Rank multiple candidates and rotate on timeout/failure.,"Lag with two peers, make the selected fallback unavailable.",P2P recovery should use available redundancy.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/net/anti_entropy.rs#L79-L88,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-079,High,Likely,Presence / authenticity,crates/flowstate-collab/src/presence.rs,119-175,Presence-state keys are accepted from encoded ephemeral updates without binding them to the authenticated sender endpoint.,A session member may spoof another participant's name/caret/color key.,Wrap presence updates in sender-authenticated envelopes and reject states for keys not derived from the transport sender.,Have peer B publish an encoded state under peer A's key.,"Presence is ephemeral, but peer identity must remain trustworthy.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/presence.rs#L119-L175,Partially mitigated; remains open,Publishing encode_self prevents an honest peer from rebroadcasting all remote roster states. Inbound ephemeral state keys are still not cryptographically/transport-bound to the sending endpoint.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-080,Medium,Lead,Session authorization,crates/flowstate-collab/src/ticket.rs,11-59,"Session tickets contain no expiry, capability, role, or revocation data.",A copied bearer ticket may remain usable for the session lifetime with editor-level access determined elsewhere.,"Define signed capabilities/roles, optional expiry, and host revocation/rotation.",Reuse an old ticket after role change or host restart.,The architecture defines roles/users but needs an authorization trust model.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/ticket.rs#L11-L59,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-081,Low,Confirmed-static,Protocol diagnostics,crates/flowstate-collab/src/ids.rs,67-90,SessionId parser accepts uppercase hex while the error says lowercase is required.,Diagnostics and canonical ticket expectations disagree.,Either reject uppercase or change the message.,Parse uppercase A-F.,Low-risk protocol consistency issue.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/ids.rs#L67-L90,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-082,Positive,Implemented,Assets / integrity,crates/flowstate/src/collab/asset_transfer.rs,120-260,Received assets are checked against expected byte length and BLAKE3 hash.,This prevents accidental/corrupt asset attachment.,Retain and add size caps before allocation/download.,No defect; adversarially test mismatch handling.,Matches canonical BLAKE3 asset addressing.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/asset_transfer.rs#L120-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-083,Positive,Implemented,Anti-entropy,crates/flowstate/src/collab/session_io.rs,47-110,Pending/missing import dependencies trigger immediate anti-entropy work.,The design does not rely only on periodic digest.,Keep this path while making failures peer-scoped and time-bounded.,No defect; retain behavior.,Matches the anti-entropy requirement.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/collab/session_io.rs#L47-L110,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-084,Critical,Confirmed-static,Projection diff,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,15-47,"When block IDs form a single move, the diff emits only MoveBlock.",Text/style/object changes in the same canonical commit are omitted.,"After structural detection, also diff all retained blocks, or derive patches directly from the Loro event batch.",Move a block and edit its content in one grouped change.,A projection event must represent the complete frontier transition.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L15-L47,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-085,Critical,Confirmed-static,Projection diff,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,31-47,Insert/delete structural diffs omit changes to retained prefix and suffix blocks.,A commit that inserts a block and edits existing content produces an incomplete UI update.,Diff unchanged-ID regions before/after the structural middle or fall back to a full snapshot.,Insert an object and edit a paragraph in one transaction.,Batched frontier invalidation must be complete.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L31-L47,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-086,High,Confirmed-static,Projection diff / performance,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,202-217,Single-block-move detection is cubic and repeatedly clones vectors.,Large documents can freeze on common reorder diffs.,Use an O(n) position map/permutation check.,Benchmark one move in 10k and 100k blocks.,Incremental projection is intended to improve responsiveness.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L202-L217,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-087,High,Confirmed-static,Projection diff / objects,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,74-112,"Object patch comparison pairs objects after sorting by ID, not canonical body order/reachability.","Registry/projection mismatches force fallback, and unreachable objects contaminate comparison.",Build object patches from reachable placeholder anchors and durable block IDs in body order.,Move/delete objects while leaving historical registry entries.,Body flow is canonical for object order.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L74-L112,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-088,Medium,Confirmed-static,Projection diff / granularity,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,92-140,ReplaceObjectBlock causes a full document structural rebuild.,Small caption/source/cell changes invalidate all paragraphs and transient state.,Patch object fields and nested-flow projections directly by durable block ID.,Edit one equation character in a large document.,Full rebuild should be exceptional.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L92-L140,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-089,Medium,Confirmed-static,Projection observability,crates/flowstate-collab/src/crdt_runtime/types.rs,194-218,"Fallback counters exist, but several ordinary structural paths inherently fall back.",Telemetry may reveal the problem but does not prevent frequent full rebuilds.,Set service-level thresholds and treat repeated fallback during normal operations as an error in tests/telemetry.,"Measure typing, image, equation, and table scenarios.",Matches the plan's observability intent but not its performance target.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/types.rs#L194-L218,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-090,Medium,Confirmed-static,Editor / layout invalidation,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,80-89,ParagraphRuns patches rebuild document sections.,Inline style-only changes trigger unrelated outline work.,Do not rebuild outline/sections unless paragraph semantic style or structure changed.,Apply underline/highlight repeatedly in a large outline.,Projection invalidation should be narrowly scoped.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L80-L89,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-091,High,Confirmed-static,Projection consistency,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,210-247,Incremental body parsing and full projection have different object behavior.,A forced fallback can visibly rearrange paragraphs around objects after apparently successful incremental edits.,Use one parser/data model for both paths.,Compare hashes before and after forced full rebuild at the same frontier.,All projections of one frontier must be identical.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L210-L247,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-092,Medium,Lead,Coordinate units,crates/flowstate-collab/src/crdt_runtime/types.rs,200-225,Invalidation ranges use Unicode positions while UI deltas use UTF-8 byte lengths.,The split is intentional in places but easy to misuse across APIs and can break non-ASCII caret mapping.,Encode units in type names/newtypes and forbid raw usize crossing boundaries.,Property-test random Unicode edits and cursor transforms.,Projection-local coordinate systems must be explicit.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/types.rs#L200-L225,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-093,Medium,Confirmed-static,Projection diff / caret mapping,crates/flowstate-collab/src/crdt_runtime/projection_patch.rs,238-307,Remote text diff reduces the entire change to one common-prefix/common-suffix replacement.,Multi-range edits in one commit produce coarse caret remapping and excessive invalidation.,Use Loro text deltas/event ranges directly and preserve multiple edits.,Apply two distant edits in one remote transaction.,Batched event processing should retain precise changed ranges.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/projection_patch.rs#L238-L307,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-094,Medium,Confirmed-static,Editor / structural performance,crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs,104-140,One structural patch invalidates all paragraph layout.,Object insertion/deletion/move scales with full-document layout and cache rebuilding.,"Invalidate only affected block rows, neighboring paragraph layout, and pagination ranges.",Insert an image in a 50k-paragraph document.,Incremental projection should preserve responsiveness.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/projection_apply.rs#L104-L140,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-095,High,Likely,Package parser / memory,crates/flowstate-document/src/package.rs,860-1040,Package decoding trusts file-provided chunk counts and lengths enough to drive large allocations.,A malformed or hostile .db8 can exhaust memory before semantic validation.,Set global file/chunk/count/asset limits and stream/seek chunks instead of allocating all payloads.,Fuzz count/length fields near usize/u64 limits.,The custom container needs defensive partial reads.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L860-L1040,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-096,High,Lead,Package parser / complexity,crates/flowstate-document/src/package.rs,860-1120,Postcard decoding has no explicit nesting/string/container resource limits.,Hostile metadata can cause excessive allocation or CPU even when outer chunks are bounded.,Use bounded decoding wrappers or validate encoded lengths before deserialization.,Coverage-guided fuzz package manifests and indexes.,External document files are untrusted input.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L860-L1120,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-097,Medium,Likely,Package integrity,crates/flowstate-document/src/package.rs,1060-1220,Integrity-index validation may not enforce one-to-one uniqueness between index entries and durable chunks.,Duplicate entries could satisfy count/membership checks while another chunk is omitted.,"Validate uniqueness by `(kind,id)` and exact set equality.",Construct duplicate index entries with one missing chunk.,Integrity index must cover every durable chunk exactly once.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L1060-L1220,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-098,Low,Confirmed-static,Package compatibility policy,crates/flowstate-document/src/package.rs,46-50,An empty integrity index is accepted as an older package.,This preserves an undocumented compatibility path despite the architecture's explicit no-old-format stance.,Either document this as v1 forward evolution or require the index for package_format_version 1.,Open a package with the index removed.,The plan rejects old development .db8 compatibility; internal v1 tolerance should be explicit.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L46-L50,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-099,High,Confirmed-static,Search cache / I/O,crates/flowstate-document/src/package.rs,337-370,Reading cached search units first reads the entire package into memory.,"Unopened-file indexing still loads all embedded assets and history, defeating the partial-read goal.",Read header/chunk table/manifest/search chunks with Seek without materializing unrelated chunks.,Index a package containing multi-gigabyte assets.,The package was intended to support fast unopened-file indexing.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L337-L370,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-100,High,Confirmed-static,Package open / memory,crates/flowstate-document/src/package.rs,650-730,Normal package read loads the complete file and all asset bytes.,"Large documents can temporarily duplicate the whole file, decoded package, snapshots, and projection in memory.",Implement a seekable package reader and lazy/content-addressed asset loading.,Profile opening a package with large images/history.,Chunked format should permit partial reads.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L650-L730,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-101,Medium,Likely,Package parser / assets,crates/flowstate-document/src/package.rs,720-1120,Asset and chunk byte lengths are not governed by a product-level maximum.,A valid-but-huge package can force unsafe memory pressure.,Define configurable limits and stream assets from disk.,Open packages with progressively larger assets.,"Asset bytes belong outside the CRDT log, but still need bounded storage.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L720-L1120,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-102,Medium,Lead,Asset association,crates/flowstate-collab/src/crdt_runtime.rs,890-938,Asset attachment validates bytes but the exact asset-ID/content-hash association needs one canonical enforcement point.,Duplicate IDs or metadata races could attach verified bytes to stale metadata.,"Require `(asset_id, expected_hash, byte_length)` equality at runtime merge and package write.",Race metadata replacement with asset arrival.,Asset references and integrity metadata must remain coherent.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L890-L938,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-103,Medium,Confirmed-static,Package size,crates/flowstate-document/src/package.rs,214-287,Initial package creation stores both latest and named-revision snapshots even when states are nearly identical.,Every new document starts with duplicate snapshot payload overhead.,Deduplicate identical snapshot bytes/frontiers and let a revision reference the latest snapshot.,Compare initial package chunk hashes/frontiers.,"Named revisions need restorable points, not duplicate identical storage.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L214-L287,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-104,Medium,Confirmed-static,Save performance,crates/flowstate-collab/src/crdt_runtime.rs,1469-1555,"Explicit save rebuilds projection cache, search cache, snapshot, indexes, and revisions synchronously.",Large documents can stall the single CRDT runtime and block typing/sync.,"Persist the update/revision atomically first, then build disposable caches on a snapshot in background and append them later.",Measure save latency on large text/table/image documents.,Disposable caches should not block canonical editing.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L1469-L1555,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-105,Positive,Implemented,Package writes,crates/flowstate/src/workspace/workspace/documents.rs,1714-1723,Whole-file writes use AtomicFile.,Final replacement avoids direct truncation of the destination.,Retain; add fsync/directory durability policy if required.,No defect; crash-test.,Matches crash-safe save intent.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1714-L1723,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-106,Positive,Implemented,Package journal,crates/flowstate-document/src/package.rs,17-34,Journal format has transaction and commit markers plus checksums.,Incomplete tail writes can be distinguished from committed records.,Keep while enforcing continuity and bounded parsing.,No defect; truncate-at-every-byte fuzzing.,Matches append-first crash-safe storage.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L17-L34,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-107,Positive,Implemented,Revisions / compaction,crates/flowstate-document/src/package.rs,500-637,Named revision frontiers receive retained snapshots during compaction.,Named points remain independently openable after unnamed update segments are compacted.,Retain and add failure-atomic staging.,No defect; verify every retained revision after repeated compaction.,Matches named-revision retention requirements.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L500-L637,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-108,Blocker,Confirmed-static,Tub indexing / data race,crates/flowstate-tub/src/lib.rs,690-810,Background search indexing rewrites the source .db8 to rebuild its search cache.,The indexer can race an active editor save and overwrite newer user content with the stale package it loaded.,Make indexing strictly read-only. Rebuild external Tantivy units in memory; only the owning editor/save pipeline may append package caches.,Open/edit a document while forcing a tub reindex of the same path.,External workspace indexing must be disposable and must not mutate canonical documents.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-tub/src/lib.rs#L690-L810,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-109,Medium,Confirmed-static,Tub indexing / watcher,crates/flowstate-tub/src/lib.rs,690-810,Fingerprint/stat data can be captured before the indexer rewrites the package.,The catalog immediately becomes stale and can trigger repeated reindex loops.,Do not rewrite source files; otherwise restat after a successful owned write.,Observe watcher events during cache rebuild.,Search indexing should be read-only.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-tub/src/lib.rs#L690-L810,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-110,Medium,Likely,Tub indexing / fingerprint,crates/flowstate-tub/src/lib.rs,220-330,File identity relies on size and modification time.,Same-size edits or coarse timestamp resolution can be missed.,Include a cheap content/package-manifest generation hash.,Perform same-size replacements within one timestamp tick.,External indexes must detect canonical frontier changes.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-tub/src/lib.rs#L220-L330,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-111,High,Confirmed-static,Package search / headings,crates/flowstate-document/src/package_search.rs,"25-29,82-101",All body paragraphs are processed before all object units.,Every object inherits the heading path left after the final paragraph instead of the heading at its anchor.,Traverse canonical body order once and emit paragraph/object units as placeholders are encountered.,Place objects under different headings and inspect indexed paths.,Search units must be section-aware and body-order aware.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L25-L101,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-112,High,Confirmed-static,Package search / reachability,crates/flowstate-document/src/package_search.rs,82-101,"Search indexes every object record in blocks_by_id, not only placeholders reachable from current flows.",Deleted/unreachable historical objects can appear in search results.,Build reachability from active flow placeholders and nested-table refs; ignore registry-only history.,Delete an object anchor but keep historical block map.,Only current-frontier projected content should be searchable.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L82-L101,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-113,High,Confirmed-static,Package search / order,crates/flowstate-document/src/package_search.rs,"90-99,341-345",Object search order follows sorted map keys.,Search result order and unit IDs do not match document order.,Emit units during body-flow traversal.,Move two objects without changing IDs and reindex.,Body flow is canonical for ordering.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L90-L345,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-114,Medium,Confirmed-static,Package search / nested tables,crates/flowstate-document/src/package_search.rs,121-154,The nested table parent-cell parameter is unused.,Search references cannot uniquely describe the nesting path and may hydrate the wrong preview/target.,Store a full ancestor table/cell path or stable nested-table ID chain.,Index two nested tables with repeated cell IDs under different parents.,Search references should use durable structural identities.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L121-L154,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-115,Medium,Confirmed-static,Package search / IDs,crates/flowstate-document/src/package_search.rs,171-197,Search unit ID includes sequential index and current frontier.,"Any edit can change IDs for otherwise unchanged units, causing churn in external indexes and UI references.",Derive IDs from durable paragraph/block/cell/flow identity plus unit kind; store frontier separately.,Edit one early paragraph and compare all unit IDs.,Search references should use durable IDs/cursors.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L171-L197,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-116,Medium,Confirmed-static,Tub search / schema,crates/flowstate-tub/src/lib.rs,520-590,Unknown unit kinds are coerced to Paragraph.,Schema corruption or new feature kinds silently change meaning.,Preserve unknown kind strings or reject with a rebuild/version error.,Inject an unknown unit_kind.,Search cache schema should be explicit and versioned.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-tub/src/lib.rs#L520-L590,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-117,Medium,Lead,Tub index atomicity,crates/flowstate-tub/src/lib.rs,330-690,SQLite catalog and Tantivy document updates are separate persistence systems without one visible generation commit.,A crash can leave catalog and full-text index referring to different document generations.,Use a generation marker and recovery/rebuild transaction protocol.,Kill the process between catalog and Tantivy commits.,Both indexes are disposable but should fail coherently.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-tub/src/lib.rs#L330-L690,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-118,Medium,Confirmed-static,Package search / empty flows,crates/flowstate-document/src/package_search.rs,157-197,"Empty caption, alt-text, equation, and cell flows produce no search unit.",Stable targets can disappear/reappear across small edits and cannot be navigated as empty structural units.,Decide whether structural units need durable empty records; at minimum keep stable identity metadata outside Tantivy body text.,Clear and retype an indexed caption.,Search cache should map durable units even when body text is temporarily empty.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L157-L197,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-119,Medium,Confirmed-static,Package search / text preservation,crates/flowstate-document/src/package_search.rs,320-325,Search flow normalization removes all leading newlines.,Leading blank paragraphs and exact cursor/text correspondence are lost.,Strip exactly one sentinel and preserve remaining flow structure.,Index a flow beginning with multiple blank paragraphs.,Search projections are derived but should correspond to canonical positions.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L320-L325,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-120,Low,Likely,Package search / cursors,crates/flowstate-document/src/package_search.rs,289-304,Sentinel-only flows may produce cursor endpoints at or around the sentinel rather than the first visible position.,Navigation to an empty unit can land at an unexpected boundary.,Define and test empty-flow cursor semantics.,Navigate to empty cell/caption/header units.,Canonical cursors need consistent affinity.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package_search.rs#L289-L304,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-121,Positive,Implemented,Search cache validation,crates/flowstate-document/src/package.rs,337-370,Cached search units are rejected when their frontier does not match the latest frontier.,Stale package search caches are not treated as canonical.,Retain while implementing partial reads.,No defect; keep tests.,Matches disposable frontier-scoped cache requirement.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L337-L370,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-122,High,Confirmed-static,DOCX export / images,crates/flowstate-docx/src/exporter/blocks.rs,80-180,Image captions are not emitted.,Caption content disappears from DOCX and PDF exports.,Export caption flow paragraphs with the image relationship and semantic paragraph styles.,Export an image with a multi-paragraph caption.,Exports must be generated from complete snapshot projections.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L80-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-123,High,Confirmed-static,DOCX export / tables,crates/flowstate-docx/src/exporter/blocks.rs,180-360,Table row spans and column spans are ignored.,Merged cells flatten or duplicate incorrectly in exported documents.,Emit DOCX vMerge/gridSpan from canonical cell spans and validate topology.,Export nested merged tables.,Tables are first-class structured content.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L180-L360,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-124,High,Confirmed-static,DOCX export / tables,crates/flowstate-docx/src/exporter/blocks.rs,180-360,Column widths and header-row/table style semantics are not fully exported.,Layout and repeated-header behavior change outside Flowstate.,"Map canonical widths/style fields to tblGrid, tcW, trPr, and table style properties.",Round-trip fixed/fraction/auto widths and header rows.,DOCX is a first-class external format.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L180-L360,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-125,High,Confirmed-static,DOCX export / equations,crates/flowstate-docx/src/exporter/blocks.rs,360-440,Equations are exported as styled text placeholders rather than OMML.,Mathematical semantics and rendering are lost.,Convert supported LaTeX/MathML source to OMML or embed a standards-compliant equation object with fallback.,Export and reopen equations in Word/LibreOffice.,Equation source remains canonical; export must render a real equation representation.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L360-L440,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-126,High,Confirmed-static,DOCX export / sections,crates/flowstate-docx/src/exporter.rs,1-260,"Section page size, margins, orientation, columns, numbering, headers, and footers are not emitted from canonical section metadata.",Page structure is lost in DOCX and therefore PDF.,Project sections distinctly and write sectPr/header/footer relationships at the correct boundaries.,Round-trip every non-default section field.,Sections/page structure are canonical Loro metadata.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter.rs#L1-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-127,Medium,Confirmed-static,DOCX export / accessibility,crates/flowstate-docx/src/exporter/blocks.rs,80-180,Image alt text is not reliably written into drawing accessibility properties.,Screen readers may not receive the canonical alt text.,Write descr/title attributes on docPr and preserve plain-text fallback only for unsupported images.,Inspect exported XML and test with accessibility tooling.,Alt text is a first-class CRDT flow.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L80-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-128,Medium,Confirmed-static,DOCX export / image fidelity,crates/flowstate-docx/src/exporter/blocks.rs,80-180,Image crop and sizing modes are only partially represented.,Fit-width/intrinsic/fixed layout can change across export.,Map all canonical image attributes to DrawingML extents/crop/alignment.,Round-trip each sizing/alignment/crop mode.,Object metadata should survive external export where supported.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L80-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-129,Medium,Confirmed-static,DOCX export / MIME,crates/flowstate-docx/src/exporter/blocks.rs,80-180,Only common PNG/JPEG images are embedded; unsupported MIME types degrade to text.,Valid assets can disappear visually.,Transcode supported source formats deterministically or provide an explicit export error/report.,Export WebP/SVG/TIFF assets.,Export should not silently omit visible content.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/exporter/blocks.rs#L80-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-130,High,Confirmed-static,PDF export,crates/flowstate-docx/src/pdf.rs,1-260,PDF export inherits all DOCX export omissions.,"Captions, equations, spans, section settings, and some images are missing or altered.",Fix snapshot-to-DOCX fidelity first or render PDF directly from the complete projection.,"Compare Flowstate render, DOCX, and PDF fixtures.",PDF export must originate from the same canonical snapshot.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/pdf.rs#L1-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-131,Critical,Confirmed-static,PDF recovery / hostile input,crates/flowstate-docx/src/pdf_recovery.rs,180-340,Embedded source recovery trusts declared original length and decompresses zstd without a strict output cap.,A crafted PDF can trigger a decompression bomb or huge allocation.,"Set hard compressed/uncompressed limits, stream decode with a bounded writer, and reject length mismatches before allocation.",Fuzz embedded payload headers and compression ratios.,PDF is untrusted external input.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/pdf_recovery.rs#L180-L340,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-132,Medium,Likely,PDF export / durability,crates/flowstate-docx/src/pdf.rs,180-330,PDF generation writes to the final output path without a complete temp-file/rename transaction.,Failure can leave a partial export that looks valid by filename.,Generate in a temp sibling and atomically rename after successful close/validation.,Kill conversion midway.,External export should not corrupt an existing destination.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/pdf.rs#L180-L330,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-133,Low,Confirmed-static,PDF export / temp files,crates/flowstate-docx/src/pdf.rs,40-100,Temporary sibling names rely mainly on timestamps.,Concurrent exports can collide.,Use tempfile/UUID-backed exclusive creation.,Launch multiple exports in the same timestamp window.,Low-risk filesystem correctness.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/pdf.rs#L40-L100,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-134,Medium,Lead,PDF recovery / name tree,crates/flowstate-docx/src/pdf_recovery.rs,80-180,Embedded-file lookup appears focused on a flat Names array.,PDFs using nested name-tree Kids may not be recoverable.,Walk the full PDF name tree recursively with depth/count limits.,Create a standards-compliant nested EmbeddedFiles name tree.,PDF source recovery should handle valid PDF structures.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/pdf_recovery.rs#L80-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-135,Medium,Confirmed-static,DOCX import / formatting,crates/flowstate-document/src/loro_import.rs,260-390,Run-length mismatch silently stops style mapping and leaves the remainder default-styled.,Corrupt or inconsistent import projection loses formatting without an explicit error.,Validate run lengths against UTF-8 boundaries and fail or report a structured import warning.,Feed mismatched run lengths and multibyte text.,"Import heuristics may drop unsupported formatting, but corruption should be observable.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_import.rs#L260-L390,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-136,Medium,Likely,DOCX import / projection,crates/flowstate-document/src/loro_import.rs,1-120,Import returns a supplied projection alongside canonical Loro instead of always reprojecting the finalized Loro state.,Normalization/metadata repair can make the initial UI differ from a later reopen.,"Build the initial projection from the final imported Loro frontier, or assert exact equivalence.",Compare import projection to `document_from_loro` for random documents.,Renderer should always consume a projection derived from canonical Loro.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_import.rs#L1-L120,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-137,High,Lead,DOCX import / sections,crates/flowstate-docx/src/interpreter/structured.rs,1-420,Section/header/footer import into the new Loro section schema is not demonstrably complete.,DOCX page structure may be dropped even though the target schema supports it.,Trace every sectPr/header/footer field into SectionMap and independent flows; add round-trip fixtures.,"Import multi-section DOCX with distinct headers, margins, columns, numbering.",DOCX import is first-class and sections are in scope.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/interpreter/structured.rs#L1-L420,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-138,Medium,Lead,DOCX import / equations,crates/flowstate-docx/src/interpreter/omml.rs,1-420,OMML conversion supports only a subset and unsupported constructs need explicit reporting.,Complex equations may silently degrade.,Return structured import warnings and retain source/fallback representation.,Corpus-test advanced OMML constructs.,"Unknown formatting/content may be reported or dropped, but should not be silent.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/interpreter/omml.rs#L1-L420,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-139,Positive,Implemented,PDF recovery format,crates/flowstate-docx/src/pdf.rs,1-260,PDF source embedding uses the new Loro-native .db8 package bytes.,The old final-state serializer is not embedded alongside it.,Retain and harden size validation.,No defect.,Matches the locked PDF source-embedding clarification.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/pdf.rs#L1-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-140,Positive,Implemented,DOCX import architecture,crates/flowstate-docx/src/lib.rs,1-120,DOCX import constructs the Loro-native document path.,Imported documents no longer need a second persistent canonical model.,Retain; make the final projection canonical-derived.,No defect.,Matches the Loro-native import pipeline.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-docx/src/lib.rs#L1-L120,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-141,Medium,Confirmed-static,Logging / disk usage,crates/flowstate/src/logging.rs,1-180,Daily log files have no retention or total-size policy.,Long-running installations can grow the log directory indefinitely.,Keep a bounded number/age/total bytes and expose cleanup failures.,Simulate many days or large-volume tracing.,Operational issue outside settings UI.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/logging.rs#L1-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-142,Low,Lead,Logging / observability,crates/flowstate/src/logging.rs,1-180,Non-blocking logging can drop messages under sustained load without a visible dropped-event metric.,The exact diagnostics needed for collaboration failures may be missing.,Expose queue saturation/drop counts and retain critical errors synchronously or in a bounded ring.,Flood tracing while blocking the writer.,Observability should remain reliable under failure.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/logging.rs#L1-L180,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-143,High,Confirmed-static,Flow editor / data loss,crates/flowstate-flow/src/persistence.rs,57-78,A corrupt or unsupported existing .fl0 file opens as a blank document.,A later save can overwrite the original file; the source comments explicitly acknowledge the risk.,"Return Result to the workspace, open a recovery/error tab, and never attach the original path to the blank fallback.","Corrupt an existing .fl0, open, edit, save.",Whole-app correctness issue; separate from the Loro .db8 architecture.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-flow/src/persistence.rs#L57-L78,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-144,High,Confirmed-static,Flow editor / durability,crates/flowstate-flow/src/persistence.rs,81-92,.fl0 save uses direct `fs::write`.,Crash/disk-full interruption can truncate the only copy.,Use AtomicFile/temp+fsync+rename and retain a recovery file.,Kill during save and inject short writes.,Whole-app persistence issue.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-flow/src/persistence.rs#L81-L92,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-145,Medium,Confirmed-static,Flow editor / hostile input,crates/flowstate-flow/src/persistence.rs,48-53,.fl0 reads and parses the entire JSON file without a size limit.,A huge file can exhaust memory.,Set a maximum file size and use bounded parsing where practical.,Open oversized JSON.,External files are untrusted input.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-flow/src/persistence.rs#L48-L53,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-146,Medium,Likely,Flow editor / recursion,crates/flowstate-flow/src/persistence.rs,121-167,Legacy recursive migration can overflow the stack on very deep nested boxes.,A crafted legacy file can crash the process.,Use an explicit stack and depth/node limits.,Generate deeply nested legacy JSON.,External file parser hardening.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-flow/src/persistence.rs#L121-L167,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-147,Medium,Confirmed-static,Flow editor / autosave UX,crates/flowstate/src/workspace/workspace/documents.rs,1450-1464,.fl0 autosave failures are printed but not retained in user-visible document status.,Users can continue editing believing changes are safe.,Keep a persistent save-error state and block silent close/discard.,Make destination unwritable.,Whole-app durability issue.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/documents.rs#L1450-L1464,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-148,Medium,Lead,Workspace / previews,crates/flowstate/src/workspace/workspace/load.rs,81-94,Recent-file previews fully decode .db8/PDF source packages.,Opening a workspace with many large recent files can cause startup I/O and memory spikes.,Read projection/search cache chunks lazily with concurrency limits.,Profile 100 recent multi-gigabyte packages.,Chunked package should enable partial preview reads.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/load.rs#L81-L94,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-149,Medium,Lead,Workspace / missing files,crates/flowstate/src/workspace/workspace/load.rs,49-60,A missing .db8 path is treated as a new blank document.,"Typos, deleted recent files, or transient mount failures can be masked as a new document at the same path.",Distinguish explicit New from Open; show missing-file state rather than silently creating.,Open a stale recent-file path.,Whole-app file lifecycle issue.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/load.rs#L49-L60,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-150,Medium,Confirmed-static,Workspace / duplicated I/O,crates/flowstate/src/workspace/workspace/load.rs,81-94,"Preview, open, and tub indexing each perform independent package reads/decodes.","Large packages are repeatedly loaded and validated, multiplying latency and memory pressure.",Share a seekable package reader/cache keyed by path+frontier generation.,"Profile one file appearing in recent preview, tub, and active open.","Derived caches should reduce, not multiply, package work.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate/src/workspace/workspace/load.rs#L81-L94,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-151,Low,Confirmed-static,Repository hygiene,flowstate.log.2026-06-16,1-1,A generated application log file is included in the PR change set.,"It adds noise, may contain environment/session data, and encourages future log commits.",Remove it and add the log pattern to .gitignore.,Inspect PR changed files.,Repository hygiene issue.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/flowstate.log.2026-06-16#L1-L1,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-152,Medium,Confirmed-static,CI / release confidence,Cargo.toml,1-40,The reviewed PR head exposes no meaningful build/test status beyond CodeRabbit in the connector.,"A 186-file refactor has no visible automated gate proving cross-platform compile, tests, or integration behavior.","Require Linux/Windows build, clippy, unit, malformed-file fuzz smoke, collaboration loopback, and crash-consistency jobs.",Check PR status after adding required workflows.,"Process risk; the architecture plan explicitly did not define a general suite, but release confidence still requires one.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/Cargo.toml#L1-L40,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-153,High,Lead,Testing / failure modes,crates/flowstate-collab/tests/anti_entropy.rs,1-400,"Current visible tests do not cover partial command-batch failure, disk failure after commit, hostile direct lengths, indexer/save races, or concurrent table/object edits.",The highest-risk paths can regress silently.,Add deterministic fault-injection and multi-replica model tests before merge.,Run the proposed scenarios under randomized scheduling.,"Not a plan violation, but a release blocker for this implementation.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/tests/anti_entropy.rs#L1-L400,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-154,Medium,Lead,Architecture / secondary API,crates/flowstate-collab/src/crdt_runtime/types.rs,78-129,A second low-level SemanticCommand API remains and uses absolute Unicode positions.,It duplicates the editor command surface and is easier to call without durable IDs/cursors or a base frontier.,Restrict it to internal tests/migrations or delete it in favor of one semantic command API.,Search all non-test call sites and remove/convert them.,The old canonical operation language was meant to be retired.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/types.rs#L78-L129,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-155,Medium,Confirmed-static,Projection invalidation,crates/flowstate-collab/src/crdt_runtime/types.rs,246-260,`body_object` records block kind strings such as `table` in fields named changed_blocks/changed_tables.,The invalidation cannot identify the exact durable object and forces broad downstream work.,Carry durable block/table IDs and affected flow/range.,Insert/edit two tables and inspect invalidation payloads.,Projection indexes are intended to support exact invalidation.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/types.rs#L246-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-156,Low,Confirmed-static,Diagnostics,crates/flowstate-collab/src/crdt_runtime/types.rs,12-28,StaleProjectionError reports only frontier byte lengths.,"Different frontiers often have the same encoded length, making logs misleading.","Include hashes/short IDs and causal relation, not raw frontier bytes.",Trigger two same-length divergent frontiers.,Observability issue.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/types.rs#L12-L28,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-157,Medium,Likely,Runtime events / memory,crates/flowstate-collab/src/crdt_runtime/types.rs,131-167,Runtime events can carry full document and package clones.,Revision/fallback operations can create large transient memory spikes in actor queues.,"Use Arc/COW snapshots, bounded channels, and references to immutable package snapshots.",Open/fork revisions in a large asset-heavy document.,Runtime isolation should avoid unnecessary copies.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime/types.rs#L131-L167,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-158,Positive,Implemented,Architecture assessment,crates/flowstate-document/src/loro_schema.rs,223-283,One continuous LoroText per body flow with sentinel newline and semantic marks is the right concurrency model.,Paragraph split and inline insertion can commute naturally.,Keep it; repair the projection and command layers around it.,Model-check split/insert concurrency cases.,Strongly desirable plan decision.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L223-L283,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-159,Positive,Implemented,Architecture assessment,crates/flowstate-document/src/loro_schema.rs,13-40,Loro maps/lists hold durable schema identity while UI data remains projected.,This removes the old local/collaboration dual-write source of truth.,Keep and remove remaining projection-native mutation paths.,No defect.,Strongly desirable plan decision.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L13-L40,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-160,Positive,Implemented,Architecture assessment,crates/flowstate-document/src/package.rs,36-176,The custom package cleanly separates canonical snapshots/updates/assets from disposable caches.,"The shape supports revisions, assets, search, and integrity in one artifact.","Keep the format, but implement seekable/lazy reads and transactional staging.",No defect.,Desirable plan decision with implementation hardening needed.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L36-L176,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-161,Positive,Implemented,Architecture assessment,crates/flowstate-document/src/loro_schema.rs,275-283,Semantic style marks use non-expanding configuration.,Paragraph/style inheritance is controlled by commands rather than accidental sticky marks.,Keep and add exhaustive split/paste style tests.,No defect.,Desirable plan decision.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L275-L283,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-162,Positive,Implemented,Architecture assessment,crates/flowstate-document/src/package.rs,500-637,Time travel uses named frontiers/snapshots rather than undo state.,Restore/fork can remain separate from per-replica undo.,Keep; make revision events uniformly persisted and synced.,No defect.,Desirable plan decision.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L500-L637,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-163,Medium,Lead,Architecture assessment / privacy,crates/flowstate-document/src/loro_schema.rs,379-433,Durable user display names and replica mappings are stored in the document.,"Saved/shared files retain collaborator identity metadata, which may be more persistent than users expect.","Define a privacy/redaction policy, stable pseudonymous IDs, and export/fork behavior.",Inspect package after collaboration and after fork/export.,"The plan asks for durable authorship, but product/privacy policy is still needed.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/loro_schema.rs#L379-L433,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-164,High,Confirmed-static,Architecture assessment / optimistic UI,crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs,6-110,The UI mutates a full projection optimistically before the runtime accepts the command.,"Without an explicit speculative overlay/rebase model, this recreates a temporary second mutable document model and causes many error/event races.","Choose one: runtime-first edits, or a formal speculative overlay containing pending intents over an immutable canonical projection.",Inject stale/failing commands and remote edits during local typing.,"The plan says the UI owns a projection, not a second authoritative mutable document.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs#L6-L110,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-165,High,Confirmed-static,Architecture assessment / inline objects,crates/gpui-flowtext/src/document/blocks.rs,1-260,The current projected editor type system is block-oriented and cannot faithfully express inline object placeholders.,The plan's inline-equation capability is not implementable by the current InputBlock/Block model.,Either extend paragraph runs with inline object spans now or lock the product to block objects for this pass.,Attempt inline equation round-trip and caret movement.,Plan decision is desirable only with a matching projection/editor representation.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/document/blocks.rs#L1-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-166,High,Confirmed-static,Architecture assessment / table IDs,crates/gpui-flowtext/src/document/blocks.rs,120-260,Projected tables expose positional rows/cells without durable IDs.,The Loro-native table plan cannot be completed by adapters around the existing type; the editor model itself must change.,Add durable row/column/cell IDs to projection and all selection/commands.,Reorder under concurrent cell edit.,"The plan's table model is desirable, but requires deeper editor rearchitecture.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/gpui-flowtext/src/document/blocks.rs#L120-L260,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-167,Medium,Confirmed-static,Architecture assessment / save cost,crates/flowstate-collab/src/crdt_runtime.rs,1469-1537,Every explicit save is both revision creation and synchronous compaction/cache regeneration.,"The revision semantics are desirable, but doing all disposable work on the edit runtime is not.",Commit revision/update fast; compact and cache asynchronously from a snapshot.,Measure save latency and collaboration pause.,"Keep the product semantics, change execution strategy.",https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-collab/src/crdt_runtime.rs#L1469-L1537,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-168,Medium,Confirmed-static,Architecture assessment / search cache ownership,crates/flowstate-tub/src/lib.rs,690-810,"Embedding a search cache is useful, but letting external indexers rewrite it is unsafe.",Cache ownership is undefined and creates write races.,Only the document runtime/save pipeline writes package caches; external tub reads or rebuilds its own disposable index.,Concurrent editor/indexer test.,Desirable cache design with incorrect ownership.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-tub/src/lib.rs#L690-L810,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-169,Medium,Lead,Architecture assessment / old format,crates/flowstate-document/src/package.rs,13-24,The no-legacy-.db8 decision is technically clean but requires unmistakable format/version UX.,Users can receive an opaque unsupported/corrupt error with no recovery path if any pre-release files escaped.,"Keep the no-importer policy if intentional, but show exact format magic/version diagnostics and preserve originals.",Open old/development .db8 samples.,Product-risk note rather than an implementation defect.,https://github.com/realartists-gitmo/flowstate/blob/0d0ecea88c71f90ddf6cc266da028242c8366646/crates/flowstate-document/src/package.rs#L13-L24,Unchanged,The latest two commits do not modify the code establishing this finding.,9cb8ad8add47db2e3cefd611053a8d99e4ce4048 +FS-170,Medium,Confirmed-static,Presence and undo / object boundaries,crates/flowstate-collab/src/crdt_runtime.rs,"260-276,884-917,1220-1255",Presence and undo cursor resolution still cannot distinguish both sides of standalone object placeholders.,"offset_for_body_unicode converts every resolved canonical position into a paragraph-only DocumentOffset. Positions immediately before and after an image/equation/table placeholder can collapse to the same preceding paragraph-end offset, so remote carets and restored selections can render on the wrong side of an object.","Resolve cursors into a projection position type that can represent Text(DocumentOffset) and Object { block_id, affinity }, then teach ExternalCaret and undo restoration to render both forms.","Add paragraph-object-paragraph tests for Before, After, and Neutral cursors on both U+FFFC boundaries, including concurrent insertion and undo restoration.","The canonical object anchor is U+FFFC, so selection projection must retain object-side affinity rather than collapsing all positions into paragraph text offsets.",https://github.com/realartists-gitmo/flowstate/blob/9cb8ad8add47db2e3cefd611053a8d99e4ce4048/crates/flowstate-collab/src/crdt_runtime.rs#L260-L276,Newly identified; open,"The new cursor conversion makes this limitation explicit. It may predate these commits, but it was not recorded in the original 169 findings.",9cb8ad8add47db2e3cefd611053a8d99e4ce4048 diff --git a/helpers/docs/collab_qa.md b/helpers/docs/collab_qa.md new file mode 100644 index 00000000..427b9e93 --- /dev/null +++ b/helpers/docs/collab_qa.md @@ -0,0 +1,97 @@ +# Flowstate Collaboration QA + +Manual QA for P2P `.db8` collaboration. Run with two or three app instances using separate working directories. Use fresh documents unless a scenario says otherwise. + +## Setup + +1. Start instance A and open or create a `.db8` document. +2. Use `Share / Collaborate...` to start a session and copy an invite. +3. Start instance B, use `Join Collaboration Session...`, paste the invite, and verify a new shared tab opens. +4. For three-peer cases, repeat join from instance C using a fresh invite from either A or B. + +## Core Sync + +1. Type in A and verify B/C update live. +2. Type in B and verify A/C update live. +3. Paste multi-paragraph rich text and verify convergence. +4. Exercise multibyte text with the fixture `a\u{00e9}\u{1f30d}\u{2028}x`. +5. Apply paragraph styles, run styles, highlights, underline, and strikethrough from different peers. +6. Insert, move, replace, and delete image/equation/table blocks. +7. Paste an image on A; verify B/C show a loading placeholder then the image. +8. Paste a different image on B; verify A/C can serve it after B has it. + +## Presence + +1. Move the caret with keyboard arrows in A; verify B sees the remote caret move. +2. Select text in B; verify A sees the caret position update. +3. Leave C or kill it; verify presence ages out within 30 seconds. +4. Reopen the share dialog and verify participant count/status remains sensible. + +## Undo And Conflicts + +1. Type several edits on A, then undo and redo; verify only A's edits are undone/redone and all peers converge. +2. Interleave A and B edits in the same paragraph, then undo on A and B independently. +3. Simultaneously edit the same table cell from A and B; verify the block converges with last-writer-wins behavior. +4. Simultaneously split a paragraph on A while B types later in the paragraph; verify no data loss and convergence. + +## Network And Recovery + +1. Disconnect B's network or block traffic, keep editing on B, then reconnect; verify anti-entropy catches up. +2. Let A leave while B and C remain; verify B and C continue editing and converging. +3. Force a large paste above the gossip inline limit; verify blob pull or later anti-entropy convergence. +4. Quit or crash a pathless joined tab; verify a recovery file exists under the temp `flowstate-collab-recovery` directory and opens as a local document on recovery. +5. Test a stale/dead invite; verify join fails with actionable copy. + +## Prompts And Leave + +1. Click `Leave session`; cancel, then leave. Verify the tab remains open as a local document. +2. Close an attached clean path-backed tab; verify leave prompt, then close. +3. Close an attached dirty path-backed tab; verify leave prompt, save prompt, and cancel at each step keeps the session attached. +4. Close a pathless attached tab; verify leave prompt, `Save As...` / `Don't Save` / `Cancel` behavior. +5. Quit with multiple attached tabs; verify the combined leave prompt, then per-tab save prompts. Cancel anywhere must abort quit. + +## Compatibility And Error Cases + +1. Paste a malformed invite; verify inline parse error and no network dial. +2. Use an unsupported-version invite if available; verify clear rejection. +3. Try joining a session already open in the same app; verify it is rejected. +4. Kill the inviter before join; verify join fails without modifying existing documents. +5. Confirm saved files are plain `.db8` snapshots and do not contain CRDT history. + +## Failure Checklist + +| Row | Scenario | Expected Result | +|---|---|---| +| F1 | Garbage or truncated ticket | Inline parse error; no network dial. | +| F2 | Inviter unreachable at join | Join times out with actionable copy. | +| F3 | Inviter reachable but snapshot pull fails | Fallback candidates are tried; all failures end in `JoinFailed`. | +| F4 | Old dead-session ticket | Join times out and copy indicates the session may be over. | +| F5 | Protocol version skew | Join rejects fast or in-session gossip is ignored with a one-time notice. | +| F6 | Lineage violation | Join meta check fails or digest mismatch is ignored; no merge. | +| F7 | Peer crash or kill | Presence ages out within 30 seconds and roster updates. | +| F8 | Transient network loss | Offline pill appears; edits accumulate and resync after reconnect. | +| F9 | Long offline window | Reconnect merges local and remote edits without data loss. | +| F10 | All other peers leave | Session remains attached as `Only you`; no auto-detach. | +| F11 | Gossip receiver lag | Digest pull runs and peers converge. | +| F12 | Oversized blob pull fails | Later anti-entropy recovers the missing update. | +| F13 | Projection drift | Self-check rebuilds from projection and clamps selection. | +| F14 | Remote patch during drag or IME | Patch is deferred, then applied safely. | +| F15 | Asset fetch fails everywhere | Placeholder persists; text sync remains unaffected. | +| F16 | Self-join with own ticket | Friendly error; no dial. | +| F17 | Share on already-attached tab | Attached view opens and can mint a fresh invite. | +| F18 | Close tab or quit while attached | Transactional prompts appear; Cancel aborts the close or quit. | +| F19 | Crash with attached pathless tab | Recovery file opens as a local document on next launch. | +| F20 | Same table cell edited concurrently | Cell converges with documented last-writer-wins behavior. | +| F21 | Peer clock skew | Presence remains sensible; document sync does not depend on wall clock. | + +## Additional Soak Checklist + +1. Simultaneously type in the same paragraph from two peers. +2. Race Enter-split on one peer against typing later in the same paragraph on another peer. +3. Race edits in the same table cell and observe last-writer-wins convergence. +4. Paste an image on each peer and verify asset placeholders resolve. +5. Undo and redo interleaved edits from multiple peers. +6. Leave and rejoin using a fresh ticket. +7. Close a laptop lid or disconnect a peer for 5 minutes, then resync. +8. Cancel close-tab prompts at each step and verify the session stays attached. +9. Run a 30-minute soak with autosave enabled on a path-backed peer. diff --git a/helpers/docs/collaboration.md b/helpers/docs/collaboration.md new file mode 100644 index 00000000..24ef884f --- /dev/null +++ b/helpers/docs/collaboration.md @@ -0,0 +1,49 @@ +# Flowstate Collaboration + +Flowstate collaboration is a transport and presence layer around the same Loro-native document used for local editing and persistence. It does not introduce a second document model, operation log, or save format. + +## Canonical data flow + +Local editing: + +1. `RichTextEditor` resolves input against its current `DocumentProjection` and queues semantic command batches tagged with that projection frontier. +2. The document or session owner sends those commands to the document's single `CrdtRuntimeHandle`. +3. The runtime rejects stale-frontier commands, mutates its owned `LoroDoc`, commits the semantic undo group, and emits a frontier-scoped projection patch or snapshot. +4. The UI applies only that derived projection result. Loro update bytes are persisted and, for a live session, published to peers. + +Remote editing: + +1. The session imports received update bytes directly into the runtime's `LoroDoc`. +2. Pending dependency status triggers immediate version-vector anti-entropy. +3. The runtime's permanent subscription produces projection invalidations and update events. +4. The UI applies derived projection patches or a defensive full projection rebuild. + +Image bytes are synchronized separately through the content-addressed asset store. Loro stores BLAKE3 identities and image metadata; missing bytes render as recoverable placeholders. + +## Ownership + +Each open document has one CRDT runtime actor. The actor owns: + +- the canonical `LoroDoc`; +- permanent Loro subscriptions; +- the per-replica Loro `UndoManager`; +- package persistence and revision checkpoints; +- projection construction and invalidation; +- update import/export and asset metadata coordination. + +A joined collaboration tab installs the session's existing runtime handle directly. It does not create a temporary mirror runtime. + +## Presence + +Presence is ephemeral. Carets and selections are encoded as Loro cursors with affinity, visual gravity, and direction. User identity is distinct from replica identity; every active replica uses a unique Loro peer ID. + +## Persistence + +Saved `.db8` files are the chunked Loro-native Flowstate package. Collaboration does not change the file format and does not maintain session-only canonical history. Explicit saves create package checkpoints/revision boundaries. PDF source recovery embeds only these package bytes. + +## Verification + +- `crates/flowstate-collab/src/crdt_runtime.rs` contains runtime, projection, undo, table, revision, and anti-entropy coverage. +- `crates/flowstate-collab/src/crdt_runtime_actor.rs` covers actor serialization and stale-frontier error preservation. +- `crates/gpui-flowtext/src/rich_text/tests/collab_capture.rs` covers semantic command capture and projection patch application. +- `helpers/docs/collab_qa.md` contains the manual multi-peer QA matrix. diff --git a/loroapi.md b/loroapi.md new file mode 100644 index 00000000..63dd1890 --- /dev/null +++ b/loroapi.md @@ -0,0 +1,34060 @@ + + +# FILE: .next/standalone/pages/docs/api/js.mdx + +import styles from "./api-reference.module.css"; +import Indent from "./indent"; +import Method from "./method"; + +
+ +# API Reference + +> _Last updated: 2026-06-16 loro-crdt@1.13.3_ + +## Overview + +Loro is a powerful Conflict-free Replicated Data Type (CRDT) library that enables real-time collaboration. If CRDTs are new to you, start with [What are CRDTs](/docs/concepts/crdt) for a gentle intro. This API reference provides comprehensive documentation for all classes, methods, and types available in the JavaScript/TypeScript binding. + +Note: Under the hood, Loro combines a Fugue-based CRDT core with Eg-walker-inspired techniques that use simple index operations and replay only the divergent history when merging. This yields fast local edits, efficient merges, and low overhead without permanent tombstones. See the primer [Event Graph Walker (Eg-walker)](/docs/advanced/event_graph_walker) and performance notes in the v1.0 blog (import/export speedups, shallow snapshots): https://loro.dev/blog/v1.0 + +## Pitfalls & Best Practices + +**Peer ID Management** + +- **Never share PeerIDs** between concurrent sessions (tabs/devices) - causes document divergence +- Use random PeerIDs (default) unless you have strict single-ownership locking +- Don't assign fixed PeerIDs to users or devices + +**UTF-16 Text Encoding** + +- All text operations use UTF-16 indices by default in JS API +- Slicing in the middle of multi-unit codepoints corrupts them +- Use `insertUtf8()`/`deleteUtf8()` for UTF-8 systems + +**Container Creation** + +- Concurrent child container creation inside the same LoroMap at same key causes overwrites +- Initialize all child containers for a LoroMap upfront when possible +- Operations on the root containers will not override each other + +- Events emit synchronously during commit/import/checkout in JS API (v1.8+). Stay on `<=1.7.x`? Await a microtask before reading batched events. +- Import/export/checkout trigger automatic commits +- Loro transactions are NOT ACID - no rollback/isolation + +**Version Control** + +- After `checkout()`, document enters read-only "detached" mode, unless `setDetachedEditing(true)` is called +- [Frontiers](/docs/concepts/frontiers) can't determine complete operation sets without history + +**Data Structure Choice** + +- Use strings in Map for URLs/IDs (LWW), LoroText for collaborative editing + +## Common Tasks & Examples + +**Getting Started** + +- **Create a document**: [`new LoroDoc()`](#LoroDoc.constructor) - Initialize a new collaborative document +- **Add containers**: [`getText`](#LoroDoc.getText), [`getList`](#LoroDoc.getList), [`getMap`](#LoroDoc.getMap), [`getTree`](#LoroDoc.getTree) +- **Listen to changes**: [`subscribe`](#LoroDoc.subscribe) - React to document modifications +- **Export/Import state**: [`export`](#LoroDoc.export) and [`import`](#LoroDoc.import) - Save and load documents + +**Real-time Collaboration** + +- **Sync between peers**: [`export`](#LoroDoc.export) with `mode: "update"` + [`import`](#LoroDoc.import)/[`importBatch`](#LoroDoc.importBatch) - Exchange incremental updates +- **Stream updates**: [`subscribeLocalUpdates`](#LoroDoc.subscribeLocalUpdates) - Send changes over WebSocket/WebRTC +- **Set unique peer ID**: [`setPeerId`](#LoroDoc.setPeerId) - Ensure each client has a unique identifier +- **Handle conflicts**: Automatic - All Loro data types are CRDTs that merge concurrent edits + +**Rich Text Editing** + +- **Create rich text**: [`getText`](#LoroDoc.getText) - Initialize a collaborative text container +- **Edit text**: [`insert`](#LoroText.insert), [`delete`](#LoroText.delete), [`applyDelta`](#LoroText.applyDelta) +- **Apply formatting**: [`mark`](#LoroText.mark) - Add bold, italic, links, custom styles +- **Copy styled snippets**: [`sliceDelta`](#LoroText.sliceDelta) - Get a Delta for a range (UTF-16; use `sliceDeltaUtf8` for byte offsets) +- **Track cursor positions**: [`getCursor`](#LoroText.getCursor) + [`getCursorPos`](#LoroDoc.getCursorPos) - Stable positions across edits +- **Configure styles**: [`configTextStyle`](#LoroDoc.configTextStyle) - Define expand behavior for marks + +**Data Structures** + +- **Ordered lists**: [`getList`](#LoroDoc.getList) - Arrays with [`push`](#LoroList.push), [`insert`](#LoroList.insert), [`delete`](#LoroList.delete) +- **Key-value maps**: [`getMap`](#LoroDoc.getMap) - Objects with [`set`](#LoroMap.set), [`get`](#LoroMap.get), [`delete`](#LoroMap.delete) +- **Hierarchical trees**: [`getTree`](#LoroDoc.getTree) - File systems, nested comments with [`createNode`](#LoroTree.createNode), [`move`](#LoroTree.move) +- **Reorderable lists**: [`getMovableList`](#LoroDoc.getMovableList) - Drag-and-drop with [`move`](#LoroMovableList.move), [`set`](#LoroMovableList.set) +- **Counters**: [`getCounter`](#LoroDoc.getCounter) - Distributed counters with [`increment`](#LoroCounter.increment) + +**Ephemeral State & Presence** + +- **User presence**: [`EphemeralStore`](#ephemeralstore) - Share cursor positions, selections, user status (not persisted) +- **Cursor syncing**: Use [`EphemeralStore.set`](#EphemeralStore.set) with cursor data from [`getCursor`](#LoroText.getCursor) +- **Live indicators**: Track who's online, typing indicators, mouse positions +- **Important**: EphemeralStore is a separate CRDT without history - perfect for temporary state that shouldn't persist + +**Version Control & History** + +- **Undo/redo**: [`UndoManager`](#undomanager) - Local undo of user's own edits +- **Time travel**: [`checkout`](#LoroDoc.checkout) to any [`frontiers`](#LoroDoc.frontiers) - Debug or review history +- **Version tracking**: [`version`](#LoroDoc.version), [`frontiers`](#LoroDoc.frontiers), [`versionVector`](#LoroDoc.versionVector) +- **Fork documents**: [`fork`](#LoroDoc.fork) or [`forkAt`](#LoroDoc.forkAt) - Create branches for experimentation +- **Merge branches**: [`import`](#LoroDoc.import) - Combine changes from forked documents + +**Performance & Storage** + +- **Incremental updates**: [`export`](#LoroDoc.export) from specific [`version`](#LoroDoc.version) - Send only changes +- **Compact history**: [`export`](#LoroDoc.export) with `mode: "snapshot"` - Full state with compressed history +- **Shallow snapshots**: [`export`](#LoroDoc.export) with `mode: "shallow-snapshot"` - State without partial history (see [Shallow Snapshots](/docs/concepts/shallow_snapshots)) + +## Basic Usage + +```typescript twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Subscribe to changes +const unsubscribe = doc.subscribe((event) => { + console.log("Document changed:", event); +}); + +// Export updates for synchronization +const updates = doc.export({ mode: "update" }); +``` + +## LoroDoc + +The `LoroDoc` class manages containers, sync, versions, and events. + +**Constructor** + +```typescript no_run +new LoroDoc(); +``` + +Creates a new Loro document with a randomly generated peer ID. + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +``` + +**Static Methods** + + +```typescript no_run +static fromSnapshot(snapshot: Uint8Array): LoroDoc +``` + + +Creates a new LoroDoc from a snapshot. This is useful for loading a document from a previously exported snapshot. + +**Parameters:** + +- `snapshot` - Binary snapshot data + +**Returns:** A new LoroDoc instance + +**Example:** + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; + +// Assume we have a snapshot from a previous export +const prevDoc = new LoroDoc(); +prevDoc.getText("text").insert(0, "Hello"); +const snapshot: Uint8Array = prevDoc.export({ mode: "snapshot" }); + +const doc = LoroDoc.fromSnapshot(snapshot); +``` + + + +**Properties** + + +```typescript no_run +readonly peerId: bigint +``` + + +Gets the peer ID of the current writer as a bigint. + +**See Also:** [PeerID Management](/docs/concepts/peerid_management) + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const id = doc.peerId; +``` + + + + +```typescript no_run +readonly peerIdStr: `${number}` +``` + + +Gets the peer ID as a decimal string. + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const idStr = doc.peerIdStr; +``` + + + +### Configuration Methods + + +```typescript no_run +setPeerId(peer: number | bigint | `${number}`): void +``` + + +Sets the peer ID for this document. It must be a number, a BigInt, or a decimal string that fits into an unsigned 64-bit integer. +See [PeerID Management](/docs/concepts/peerid_management) for why uniqueness matters in distributed systems. + +**Parameters:** + +- `peer` - Peer ID as number, bigint, or decimal string + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setPeerId("42"); +``` + +**⚠️ Critical Pitfall:** Never let two parallel peers (e.g., multiple tabs/devices) share the same PeerID — it creates duplicate op IDs and causes document divergence. Common mistakes: + +- Don't assign a fixed PeerId to a user (users have multiple devices) +- Don't assign a fixed PeerId to a device (multiple tabs can open the same document) +- If you must reuse PeerIDs, enforce single ownership with strict locking mechanisms +- Best practice: Use random IDs (default behavior) unless you have a strong reason not to + +See [PeerID reuse](/docs/tutorial/tips) for safe reuse patterns. + + + + +```typescript no_run +setRecordTimestamp(auto_record: boolean): void +``` + + +Configures whether to automatically record timestamps for changes. Timestamps use Unix time (seconds since epoch). Learn more about storing timestamps and typical use cases in [Storing Timestamps](/docs/advanced/timestamp). + +**Parameters:** + +- `auto_record` - Whether to automatically record timestamps + +**⚠️ Important:** This setting doesn't persist in exported Updates or Snapshots. You must reapply this configuration each time you initialize a document. + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setRecordTimestamp(true); +``` + + + + +```typescript no_run +setChangeMergeInterval(interval: number): void +``` + + +Sets the interval in milliseconds for merging continuous local changes into a single change record. In Loro, multiple low-level operations are grouped into higher-level Changes for readability and syncing. See [Operations and Changes](/docs/concepts/operations_changes). + +**Parameters:** + +- `interval` - Merge interval in milliseconds + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setChangeMergeInterval(1000); // Merge changes within 1 second +``` + + + + +```typescript no_run +configTextStyle(styles: StyleConfig): void +``` + + +Configures the behavior of text styles (marks) in rich text containers. Marks can expand when edits happen at their edges (before/after/both/none). For a primer on rich text and marks in Loro, see [Text](/docs/tutorial/text). + +**Parameters:** + +- `styles` - Configuration object mapping style names to their config + +**StyleConfig Type:** + +```typescript no_run +type StyleConfig = Record< + string, + { + expand?: "after" | "before" | "both" | "none"; + } +>; +``` + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + italic: { expand: "none" }, + link: { expand: "none" }, +}); +``` + + + + +```typescript no_run +configDefaultTextStyle(style?: { expand: "after" | "before" | "both" | "none" }): void +``` + + +Configures the default text style for the document when using LoroText. If undefined is provided, the default style is reset. + +**Parameters:** + +- `style` - Default style configuration (optional) + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.configDefaultTextStyle({ expand: "after" }); +``` + + + +### Container Access Methods + +**📝 Note:** Creating root containers (e.g., `doc.getText("...")`) does not record operations; nested container creation (e.g., `map.setContainer(...)`) does. + +**⚠️ Pitfall:** Avoid concurrent creation of regular child containers with the same key in LoroMaps. Instead of: + +```ts no_run +// Dangerous - concurrent peers can create different child container IDs +doc.getMap("user").getOrCreateContainer(userId, new LoroMap()); +``` + +Use a mergeable child container when the child should be identified by its logical position: + +```ts no_run +doc.getMap("user").ensureMergeableMap(userId); +``` + +Alternatively, use a unique root container if that better fits your model: + +```ts no_run +doc.getMap("user." + userId); +``` + + +```typescript no_run +getText(name: string): LoroText +``` + + + +Gets or creates a text container with the given name. New to LoroText and marks? See [Text](/docs/tutorial/text). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroText` instance + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("content"); +text.insert(0, "Hello"); +``` + + + + +```typescript no_run +getList(name: string): LoroList +``` + + + +Gets or creates a list container with the given name. Unsure whether to use List or MovableList? See [List and Movable List](/docs/tutorial/list) and the type selection guide [Choosing CRDT Types](/docs/concepts/choose_crdt_type). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroList` instance + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const list = doc.getList("items"); +list.push("Item 1"); +``` + + + + +```typescript no_run +getMap(name: string): LoroMap +``` + + +Gets or creates a map container with the given name. See [Map](/docs/tutorial/map) for basics and patterns. + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroMap` instance + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const map = doc.getMap("settings"); +map.set("theme", "dark"); +``` + + + + +```typescript no_run +getTree(name: string): LoroTree +``` + + +Gets or creates a tree container with the given name. Learn about hierarchical editing and moves in [Tree](/docs/tutorial/tree). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroTree` instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const tree = doc.getTree("fileSystem"); +const root = tree.createNode(); +``` + + + + +```typescript no_run +getCounter(name: string): LoroCounter +``` + + +Gets or creates a counter container with the given name. Counters are special CRDTs that sum concurrent increments; see [Counter](/docs/tutorial/counter). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroCounter` instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const counter = doc.getCounter("likes"); +counter.increment(1); +``` + + + + +```typescript no_run +getMovableList(name: string): LoroMovableList +``` + + +Gets or creates a movable list container with the given name. MovableList is designed for reordering with concurrent moves. See [List and Movable List](/docs/tutorial/list) and [Choosing CRDT Types](/docs/concepts/choose_crdt_type). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroMovableList` instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const movableList = doc.getMovableList("tasks"); +movableList.push("Task 1"); +movableList.push("Task 2"); +movableList.push("Task 3"); +movableList.move(0, 2); // Move first item to third position +``` + + + + +```typescript no_run +getContainerById(id: ContainerID): Container | undefined +``` + + +Gets a container by its unique ID. Container IDs (CID) uniquely reference containers across updates; see [Container ID](/docs/advanced/cid) and [Container](/docs/concepts/container). + +**Parameters:** + +- `id` - The container ID + +**Returns:** The container instance or undefined if not found + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +const textId = text.id; +const sameText = doc.getContainerById(textId); +``` + + + +### Import/Export Methods + + +```typescript no_run +export(mode?: ExportMode): Uint8Array +``` + + +Exports the document in various formats for synchronization or persistence. For a walkthrough of export modes—snapshot, update, shallow-snapshot, and updates-in-range—see [Export Mode](/docs/tutorial/encoding). Shallow snapshots remove history while keeping current state; see [Shallow Snapshots](/docs/concepts/shallow_snapshots). VersionVector and Frontiers are two ways to represent versions; see [Version Vector](/docs/concepts/version_vector) and [Frontiers](/docs/concepts/frontiers). + +**Parameters:** + +- `mode` - Export configuration (optional) + +**ExportMode Options:** + +```typescript no_run +type ExportMode = + | { mode: "snapshot" } + | { mode: "update"; from?: VersionVector } + | { mode: "shallow-snapshot"; frontiers: Frontiers } + | { mode: "updates-in-range"; spans: { id: OpId; len: number }[] }; +``` + +**Returns:** Encoded binary data + +**⚠️ Important Notes:** + +- **Shallow snapshots**: Cannot import updates from before the shallow start point. Peers can only sync if they have versions after this point. +- **Auto-commit**: The document automatically commits pending operations before export. +- **Performance**: Export new snapshots periodically to reduce import times for new peers. + +**Examples:** + +```typescript no_run +import { LoroDoc, VersionVector } from "loro-crdt"; + +const doc = new LoroDoc(); +// ... make some changes to the document ... + +// Export full snapshot +const snapshot = doc.export({ mode: "snapshot" }); + +// Export updates from a specific version +const lastSyncVersion = doc.version(); // Get current version +// ... make more changes ... +const updates = doc.export({ + mode: "update", + from: lastSyncVersion, +}); + +// Export shallow snapshot at current version +const shallowSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), +}); +``` + + + + +```typescript no_run +import(data: Uint8Array): ImportStatus +``` + + +Imports updates or snapshots into the document. Returns an `ImportStatus` describing which peer ranges were applied or are pending. See [Sync](/docs/tutorial/sync) and [Import Status](/docs/concepts/import_status) for how Loro handles out-of-order and partial updates. + +**Parameters:** + +- `data` - Binary data or another LoroDoc to import from + +**⚠️ Important:** LoroDoc will automatically commits pending operations before import. If the doc is in detached mode, the imported operations are recorded into OpLog but not applied to DocState until you call `attach()`, see [Attached vs Detached States](/docs/concepts/attached_detached) adn [OpLog and DocState](/docs/concepts/oplog_docstate). + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Receive updates from another peer (e.g., via network) +const otherDoc = new LoroDoc(); +otherDoc.getText("text").insert(0, "Hello"); +const updates: Uint8Array = otherDoc.export({ mode: "update" }); + +// Import binary updates +const status = doc.import(updates); +console.log(status.success); +``` + + + + +```typescript no_run +importBatch(data: Uint8Array[]): ImportStatus +``` + + +Efficiently imports multiple updates in a single batch operation. See [Batch Import](/docs/advanced/import_batch) for performance considerations and usage. + +**Parameters:** + +- `data` - Array of binary updates + +**Example:** + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +declare const update1: Uint8Array; +declare const update2: Uint8Array; +declare const update3: Uint8Array; + +// Usage example: +const updates = [update1, update2, update3]; +const status = doc.importBatch(updates); +``` + + + + +```typescript no_run +exportJsonUpdates(start?: VersionVector, end?: VersionVector, withPeerCompression?: boolean): JsonSchema +``` + + +Exports updates in JSON format for debugging or alternative storage. See [Export Mode](/docs/tutorial/encoding) for format details and trade-offs. + +**Parameters:** + +- `start` - Starting version (optional) +- `end` - Ending version (optional) + +**Returns:** JSON representation of updates + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const jsonUpdates = doc.exportJsonUpdates(); +console.log(JSON.stringify(jsonUpdates, null, 2)); +``` + + + + +```typescript no_run +importJsonUpdates(json: string | JsonSchema): void +``` + + +Imports updates from JSON format. Useful for debugging, migration, or custom storage layers; see [Export Mode](/docs/tutorial/encoding). + +**Parameters:** + +- `json` - JSON string or object containing updates + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const otherDoc = new LoroDoc(); +otherDoc.getText("text").insert(0, "Hello"); +const jsonStr = otherDoc.exportJsonUpdates(); +doc.importJsonUpdates(jsonStr); +``` + + + +## Versioning + +Work with the history DAG using frontiers (heads) and version vectors. Switch, branch, and merge versions safely without manual conflict resolution. See [Versioning Deep Dive](/docs/advanced/version_deep_dive) and [Attached vs Detached States](/docs/concepts/attached_detached). + +### Version Control Methods + + +```typescript no_run +checkout(frontiers: Frontiers): void +``` + + +Checks out the document to a specific version, making it read-only at that point in history. This is the core of time travel; see [Time Travel](/docs/tutorial/time_travel) and [Version](/docs/tutorial/version). + +**Parameters:** + +- `frontiers` - Array of OpIds representing the target version + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +// Make some changes... +doc.checkout(frontiers); // Go back to previous version +``` + + + + +**⚠️ Important:** In Loro 1.0, `version()`/`frontiers()` include pending (uncommitted) local operations. + +**📝 Note:** After `checkout()`, the document enters "detached" mode and becomes read-only by default. Use `attach()` or `checkoutToLatest()` to return to editing mode. See [Version Deep Dive](/docs/advanced/version_deep_dive) and [Attached vs Detached States](/docs/concepts/attached_detached). + + + + +```typescript no_run +checkoutToLatest(): void +``` + + +Returns the document to the latest version after a checkout. Related concepts: [Frontiers](/docs/concepts/frontiers) and [Version Vector](/docs/concepts/version_vector). + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.checkoutToLatest(); +``` + + + + +```typescript no_run +attach(): void +``` + + +Attaches the document to track latest changes after being detached. See [Attached vs Detached States](/docs/concepts/attached_detached) for how Loro separates current state from history. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.attach(); +``` + + + + +```typescript no_run +detach(): void +``` + + +Detaches the document from tracking latest changes, freezing it at current version. See [Attached vs Detached States](/docs/concepts/attached_detached). + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.detach(); +``` + + + + +```typescript no_run +fork(): LoroDoc +``` + + +Creates a new document that is a fork of the current one with a new peer ID. Forking is useful for branching workflows; see [Version](/docs/tutorial/version). + +**Returns:** A new LoroDoc instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const forkedDoc = doc.fork(); +``` + + + + +```typescript no_run +forkAt(frontiers: Frontiers): LoroDoc +``` + + +Creates a fork at a specific version in history. Learn more about versions, DAG history, and heads in [Version Deep Dive](/docs/advanced/version_deep_dive). + +**Parameters:** + +- `frontiers` - The version to fork from + +**Returns:** A new LoroDoc instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const forkedDoc = doc.forkAt(frontiers); +``` + + + +## Events & Transactions + +React to changes and group local operations into transactions. Starting in v1.8, events are delivered synchronously; older releases require awaiting a microtask. See [Event Handling](/docs/tutorial/event) and [Transaction Model](/docs/concepts/transaction_model). + +### Subscription Methods + + +```typescript no_run +subscribe(listener: (event: LoroEventBatch) => void): () => void +``` + + +Subscribes to all document changes. See [Event Handling](/docs/tutorial/event) for the event model and best practices. + +**Parameters:** + +- `listener` - Callback function that receives change events + +**Returns:** Unsubscribe function + +**Event Structure:** + +```typescript no_run +interface LoroEventBatch { + by: "local" | "import" | "checkout"; + origin?: string; + currentTarget?: ContainerID; + events: LoroEvent[]; + from: Frontiers; + to: Frontiers; +} +``` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const unsubscribe = doc.subscribe((event) => { + console.log("Change type:", event.by); + event.events.forEach((e) => { + console.log("Container changed:", e.target); + console.log("Diff:", e.diff); + }); +}); + +// Later: unsubscribe(); +``` + +**⚠️ Important:** Events are emitted synchronously as of v1.8. If you are pinned to `<=1.7.x`, await a microtask before reading the batch. + +```ts no_run +doc.commit(); +// Events have already been delivered in v1.8+ +// await Promise.resolve(); // Only needed on <=1.7.x +``` + +**📝 Note:** Multiple operations before a commit are batched into a single event. See [Event Handling](/docs/tutorial/event). + + + + +```typescript no_run +subscribeLocalUpdates(f: (bytes: Uint8Array) => void): () => void +``` + + +Subscribes only to local changes, useful for syncing with remote peers. This is typically wired to your transport layer; see [Sync](/docs/tutorial/sync). + +**Parameters:** + +- `f` - Callback that receives binary updates + +**Returns:** Unsubscribe function + +**Example:** + +```ts no_run threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +declare const websocket: { send: (data: Uint8Array) => void }; + +// Usage example: +const unsubscribe = doc.subscribeLocalUpdates((updates) => { + // Send updates to remote peers + websocket.send(updates); +}); +``` + + + + +```typescript no_run +subscribeFirstCommitFromPeer(f: (e: { peer: PeerID }) => void): () => void +``` + + +Subscribes to the first commit from each peer, useful for tracking peer metadata. + +**Parameters:** + +- `f` - Callback that receives peer information + +**Returns:** Unsubscribe function + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.subscribeFirstCommitFromPeer(({ peer }) => { + // Store peer metadata + doc.getMap("peers").set(peer, { + joinedAt: Date.now(), + name: `User ${peer}`, + }); +}); +``` + + + +### Transaction Methods + + +```typescript no_run +commit(options?: { origin?: string, message?: string, timestamp?: number }): void +``` + + +Commits pending changes as a single transaction. A transaction groups operations into a Change; see [Operations and Changes](/docs/concepts/operations_changes). + +**⚠️ Critical Distinction:** Loro transactions are NOT ACID database transactions: + +- No rollback capability +- No isolation guarantees +- Purpose: Bundle local operations for event batching and history grouping +- Many operations (import/export/checkout) trigger implicit commits + +See [Transaction Model](/docs/concepts/transaction_model). + +**Parameters:** + +- `options` - Optional commit configuration + - `message` - Commit message (persisted in the document like a git commit message, visible to all peers after sync) + - `origin` - Origin identifier (local only - used for marking local events, remote peers won't see this) + - `timestamp` - Unix timestamp in seconds (see [Storing Timestamps](/docs/advanced/timestamp)) + +**Important distinction:** + +- `message` is persisted in the document's history and will be synchronized to all peers, similar to git commit messages +- `origin` is only used locally for filtering events (e.g., excluding certain origins from undo) and is NOT synchronized to remote peers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.commit({ + message: "Updated document title", // Persisted & synced to all peers + origin: "user-action", // Local only, for event filtering + timestamp: Math.floor(Date.now() / 1000), +}); +``` + + + +### Query Methods + + +```typescript no_run +toJSON(): Value +``` + + +Converts the entire document to a JSON-compatible value. If you prefer a structure where sub-containers are referenced by ID (for privacy or streaming), use getShallowValue(); see [Shallow Snapshots](/docs/concepts/shallow_snapshots). + +**Returns:** JSON representation of the document + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const json = doc.toJSON(); +console.log(JSON.stringify(json, null, 2)); +``` + + + + +```typescript no_run +getShallowValue(): Record +``` + + +Gets a shallow representation where sub-containers are represented by their IDs. This is helpful when you want to share structure without history; see [Shallow Snapshots](/docs/concepts/shallow_snapshots). + +**Returns:** Shallow JSON value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const shallow = doc.getShallowValue(); +// Sub-containers appear as: "cid:..." +``` + + + + +```typescript no_run +getDeepValueWithID(): any +``` + + +Gets the deep value of the document with container IDs preserved. This is useful when you need to traverse the document structure while maintaining references to container IDs. + +**Returns:** Document value with container IDs + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const deepValue = doc.getDeepValueWithID(); +``` + + + + +```typescript no_run +version(): VersionVector +``` + + +Gets the current version vector of the document. Version vectors track how much data from each peer you’ve seen; see [Version Vector](/docs/concepts/version_vector). + +**Returns:** Map from PeerID to counter + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.version(); +console.log(vv.toJSON()); +``` + + + + +```typescript no_run +frontiers(): Frontiers +``` + + +Gets the current frontiers (heads) of the document. Frontiers are a compact representation of a version; see [Frontiers](/docs/concepts/frontiers) for when to use them instead of version vectors. + +**📝 Note:** Frontiers are a compact version representation. + +**⚠️ Limitation:** When you have a Frontier pointing to operations you don't know about, you cannot determine the complete set of operation IDs included in that version. Version Vectors don't have this limitation but are more verbose. See [Frontiers](/docs/concepts/frontiers) for trade-offs. + +**Returns:** Array of OpIds + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +// Can be used for checkouts or shallow snapshots +``` + + + + +```typescript no_run +diff(from: Frontiers, to: Frontiers, for_json?: boolean): [ContainerID, Diff | JsonDiff][] +``` + + +Calculates differences between two versions. Understanding how Loro computes diffs benefits from the history DAG model; see [Version Deep Dive](/docs/advanced/version_deep_dive). + +**Parameters:** + +- `from` - Starting frontiers +- `to` - Ending frontiers +- `for_json` - If true, returns JsonDiff format (default: true) + +**Returns:** Array of container IDs and their diffs + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const fromFrontiers = doc.frontiers(); +// Make changes... +const toFrontiers = doc.frontiers(); + +const diffs = doc.diff(fromFrontiers, toFrontiers); +diffs.forEach(([containerId, diff]) => { + console.log(`Container ${containerId} changed:`, diff); +}); +``` + + + +### Pre-Commit Hook + + +```typescript no_run +subscribePreCommit(f: (e: { changeMeta: Change, origin: string, modifier: ChangeModifier }) => void): () => void +``` + + +Subscribe to the pre-commit event. You can modify the message and timestamp of the next change. This hook runs right before a Change is recorded; see [Transaction Model](/docs/concepts/transaction_model) and [Operations and Changes](/docs/concepts/operations_changes). + +Pitfall: `commit()` can be triggered implicitly by `import`, `export`, and `checkout`. Use this hook to attach metadata even for those implicit commits. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const unsubscribe = doc.subscribePreCommit(({ modifier }) => { + modifier + .setMessage("Tagged by pre-commit") + .setTimestamp(Math.floor(Date.now() / 1000)); +}); +doc.getText("text").insert(0, "Hello"); +doc.commit(); +unsubscribe(); +``` + + + +### Cursor Utilities + + +```typescript no_run +getCursorPos(cursor: Cursor): { update?: Cursor, offset: number, side: Side } +``` + + +Resolve a stable `Cursor` to an absolute position. Cursors remain valid across concurrent edits; see [Cursor and Stable Positions](/docs/concepts/cursor_stable_positions) and [Cursor tutorial](/docs/tutorial/cursor). The side controls affinity when the cursor sits at an insertion boundary. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "abc"); + +// Get cursor at position 1 +const c0 = text.getCursor(1); +const pos = doc.getCursorPos(c0!); +console.log(pos.offset); // 1 +``` + + + +### Pending Operations + + +```typescript no_run +getUncommittedOpsAsJson(): JsonSchema | undefined +``` + + +Get pending operations from the current transaction in JSON format. Useful for debugging what will be included in the next Change; see [Transaction Model](/docs/concepts/transaction_model). + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Hello"); +const pending = doc.getUncommittedOpsAsJson(); +doc.commit(); +const none = doc.getUncommittedOpsAsJson(); // undefined after commit +``` + + + +### Change Graph & History + +These APIs traverse the history DAG of changes (ancestors/descendants, spans). If this sounds unfamiliar, start with Loro's [Versioning Deep Dive](/docs/advanced/version_deep_dive) and the [Event Graph Walker](/docs/concepts/event_graph_walker). + + +```typescript no_run +travelChangeAncestors(ids: OpId[], f: (change: Change) => boolean): void +``` + + +Visit ancestors of the given changes in causal order. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello"); +doc.commit(); +const head = doc.frontiers(); +doc.travelChangeAncestors(head, (change) => { + console.log(change.peer, change.counter); + return true; // continue +}); +``` + + + + + ```typescript no_run findIdSpansBetween(from: Frontiers, to: Frontiers): + VersionVectorDiff ``` + +Find the op id spans that lie between two versions. + + +```typescript no_run +exportJsonInIdSpan(idSpan: { peer: PeerID, counter: number, length: number }): JsonChange[] +``` + + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const a = new LoroDoc(); +const b = new LoroDoc(); + +// Usage example: +a.getText("text").update("Hello"); +a.commit(); +const snapshot = a.export({ mode: "snapshot" }); +let printed: any; +b.subscribe((e) => { +const spans = b.findIdSpansBetween(e.from, e.to); +const changes = b.exportJsonInIdSpan(spans.forward[0]); +printed = changes; +}); +b.import(snapshot); + +```` + + + +```typescript no_run +getChangedContainersIn(id: OpId, len: number): ContainerID[] +```` + + + +Get container IDs modified in the given ID range. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getList("list").insert(0, 1); +doc.commit(); +const head = doc.frontiers()[0]; +const containers = doc.getChangedContainersIn(head, 1); +``` + + + +### Revert & Apply Diff + + +```typescript no_run +revertTo(frontiers: Frontiers): void +``` + + +Revert the document to a given version by generating inverse operations. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setPeerId("1"); +const t = doc.getText("text"); +t.update("Hello"); +doc.commit(); +doc.revertTo([{ peer: "1", counter: 1 }]); +``` + + + + +```typescript no_run +applyDiff(diff: [ContainerID, Diff | JsonDiff][]): void +``` + + +Apply a batch of diffs to the document. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); + +// Usage example: +doc1.getText("text").insert(0, "Hello"); +const diff = doc1.diff([], doc1.frontiers()); +doc2.applyDiff(diff); +``` + +**Workflow example (squash-like diffs):** For PR-style reviews, combine `diff` and `applyDiff` to send a compact change set between a base version and a new version. Operations that cancel out (insert + delete) are compressed away. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const baseDoc = new LoroDoc(); +const baseText = baseDoc.getText("text"); +baseText.insert(0, "hello world"); + +// Fork to make isolated edits +const newDoc = baseDoc.fork(); +const newText = newDoc.getText("text"); +newText.insert(0, "abc"); +newText.delete(0, 4); + +const diff = newDoc.diff(baseDoc.frontiers(), newDoc.frontiers()); +console.log(diff); +// [ +// [ +// "cid:root-text:Text", +// { type: "text", diff: [ { delete: 1 } ] } +// ] +// ] + +baseDoc.applyDiff(diff); +console.log(baseDoc.toJSON()); +// { text: "ello world" } +``` + + + +### Detached Editing + + +```typescript no_run +setDetachedEditing(enable: boolean): void +``` + + +Enables or disables detached editing mode. Detached editing lets you stage edits separate from the latest head; see [Attached vs Detached States](/docs/concepts/attached_detached). + +**Parameters:** + +- `enable` - Whether to enable detached editing + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setDetachedEditing(true); +``` + + + + +```typescript no_run +isDetachedEditingEnabled(): boolean +``` + + +Checks if detached editing mode is enabled. + +**Returns:** True if detached editing is enabled + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const enabled = doc.isDetachedEditingEnabled(); +``` + + + + +```typescript no_run +isDetached(): boolean +``` + + +Checks if the document is currently detached. + +**Returns:** True if document is detached + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +console.log(doc.isDetached()); +``` + + + +### Commit Options Helpers + + +```typescript no_run +setNextCommitMessage(msg: string): void +``` + + +Sets the message for the next commit. + +**Parameters:** + +- `msg` - Commit message + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitMessage("User action"); +``` + + + + +```typescript no_run +setNextCommitOrigin(origin: string): void +``` + + +Sets the origin for the next commit. + +**Parameters:** + +- `origin` - Origin identifier + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitOrigin("ui"); +``` + + + + +```typescript no_run +setNextCommitTimestamp(timestamp: number): void +``` + + +Sets the timestamp for the next commit. + +**Parameters:** + +- `timestamp` - Unix timestamp in seconds + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitTimestamp(Math.floor(Date.now() / 1000)); +``` + + + + +```typescript no_run +setNextCommitOptions(options: { origin?: string, timestamp?: number, message?: string }): void +``` + + +Sets multiple options for the next commit. + +**Parameters:** + +- `options` - Commit options object + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitOptions({ origin: "ui", message: "batch" }); +doc.getText("text").insert(0, "Hi"); +doc.commit(); +``` + + + + +```typescript no_run +clearNextCommitOptions(): void +``` + + +Clears all pending commit options. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.clearNextCommitOptions(); +``` + + + +### Version & Frontier Utilities + + +```typescript no_run +frontiersToVV(frontiers: Frontiers): VersionVector +``` + + +Converts frontiers to a version vector. + +**Parameters:** + +- `frontiers` - Frontiers to convert + +**Returns:** Version vector representation + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const vv = doc.frontiersToVV(frontiers); +``` + + + + +```typescript no_run +vvToFrontiers(vv: VersionVector): Frontiers +``` + + +Converts a version vector to frontiers. + +**Parameters:** + +- `vv` - Version vector to convert + +**Returns:** Frontiers representation + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.version(); +const frontiers = doc.vvToFrontiers(vv); +``` + + + + +```typescript no_run +oplogVersion(): VersionVector +``` + + +Gets the oplog version vector. + +**Returns:** Oplog version vector + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.oplogVersion(); +``` + + + + +```typescript no_run +oplogFrontiers(): Frontiers +``` + + +Gets the oplog frontiers. + +**Returns:** Oplog frontiers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.oplogFrontiers(); +``` + + + + +```typescript no_run +cmpWithFrontiers(frontiers: Frontiers): -1 | 0 | 1 +``` + + +Compares current document state with given frontiers. + +**Parameters:** + +- `frontiers` - Frontiers to compare with + +**Returns:** -1 if behind, 0 if equal, 1 if ahead + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const cmp = doc.cmpWithFrontiers(frontiers); +``` + + + + +```typescript no_run +cmpFrontiers(a: Frontiers, b: Frontiers): -1 | 0 | 1 | undefined +``` + + +Compares two frontiers. + +**Parameters:** + +- `a` - First frontiers +- `b` - Second frontiers + +**Returns:** -1 if a < b, 0 if equal, 1 if a > b, undefined if incomparable + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const f1 = doc.frontiers(); +const f2 = doc.frontiers(); +const cmp = doc.cmpFrontiers(f1, f2); +``` + + + +### JSONPath & Path Queries + +Use simple path strings and JSONPath to fetch nested values and containers. Paths are formed from root container names and keys (e.g., map/key or list/0). For container IDs, see [Container ID](/docs/advanced/cid). + + +```typescript no_run +getByPath(path: string): Value | Container | undefined +``` + + +Gets a value or container by its path. + +**Parameters:** + +- `path` - Path string (e.g., "map/key") + +**Returns:** Value or container at the path, or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("key", 1); + +// Usage example: +const value = doc.getByPath("map/key"); +``` + + + + +```typescript no_run +getPathToContainer(id: ContainerID): (string | number)[] | undefined +``` + + +Gets the path to a container by its ID. + +**Parameters:** + +- `id` - Container ID + +**Returns:** Array representing the path, or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const path = doc.getPathToContainer(map.id); +``` + + + + +```typescript no_run +JSONPath(jsonpath: string): any[] +``` + + +Queries the document using JSONPath syntax. + +**Parameters:** + +- `jsonpath` - JSONPath query string + +**Returns:** Array of matching values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("key", 1); + +// Usage example: +const results = doc.JSONPath("$.map"); +``` + + + +### Shallow Doc Utilities + +These helpers relate to shallow snapshots and redaction. If you need a refresher on what “shallow” means, see [Shallow Snapshots](/docs/concepts/shallow_snapshots). + + +```typescript no_run +shallowSinceVV(): VersionVector +``` + + +Gets the version vector since which the document is shallow. + +**Returns:** Version vector + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.shallowSinceVV(); +``` + + + + +```typescript no_run +shallowSinceFrontiers(): Frontiers +``` + + +Gets the frontiers since which the document is shallow. + +**Returns:** Frontiers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.shallowSinceFrontiers(); +``` + + + + +```typescript no_run +isShallow(): boolean +``` + + +Checks if the document is shallow. + +**Returns:** True if document is shallow + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const shallow = doc.isShallow(); +``` + + + + +```typescript no_run +setHideEmptyRootContainers(hide: boolean): void +``` + + +Controls whether empty root containers are hidden in JSON output. + +**Parameters:** + +- `hide` - Whether to hide empty root containers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setHideEmptyRootContainers(true); +// Now empty roots are hidden in toJSON() +``` + + + + +```typescript no_run +deleteRootContainer(cid: ContainerID): void +``` + + +Deletes a root container. + +**Parameters:** + +- `cid` - Container ID to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Usage example: +doc.deleteRootContainer(map.id); +``` + + + + +```typescript no_run +hasContainer(id: ContainerID): boolean +``` + + +Checks if a container exists in the document. + +**Parameters:** + +- `id` - Container ID to check + +**Returns:** True if container exists + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const exists = doc.hasContainer(map.id); +``` + + + +### JSON Serialization with Replacer + + +```typescript no_run +toJsonWithReplacer(replacer: (key: string | number, value: Value | Container) => Value | Container | undefined): Value +``` + + +Customize JSON serialization of containers and values. + +**Parameters:** + +- `replacer` - Function to transform values during serialization + +**Returns:** Customized JSON value + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const json = doc.toJsonWithReplacer((key, value) => { + if (value instanceof LoroText) { + return value.toDelta(); + } + return value; +}); +``` + + + +### Stats & Introspection + + +```typescript no_run +debugHistory(): void +``` + + +Prints debug information about the document history. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.debugHistory(); +``` + + + + +```typescript no_run +changeCount(): number +``` + + +Gets the total number of changes in the document. + +**Returns:** Number of changes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const changes = doc.changeCount(); +``` + + + + +```typescript no_run +opCount(): number +``` + + +Gets the total number of operations in the document. + +**Returns:** Number of operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const ops = doc.opCount(); +``` + + + + +```typescript no_run +getAllChanges(): Map +``` + + +Gets all changes grouped by peer ID. + +**Returns:** Map of peer ID to changes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const changes = doc.getAllChanges(); +``` + + + + +```typescript no_run +getChangeAt(id: OpId): Change +``` + + +Gets a specific change by operation ID. + +**Parameters:** + +- `id` - Operation ID + +**Returns:** Change object + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "hello"); +doc.commit(); +const changes = doc.getAllChanges(); +const change = changes.get(doc.peerIdStr)?.[0]; +``` + + + + +```typescript no_run +getChangeAtLamport(peer_id: string, lamport: number): Change | undefined +``` + + +Gets a change by peer ID and Lamport timestamp. + +**Parameters:** + +- `peer_id` - Peer ID +- `lamport` - Lamport timestamp + +**Returns:** Change object or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "hello"); +const change = doc.getChangeAtLamport(doc.peerIdStr, 1); +``` + + + + +```typescript no_run +getOpsInChange(id: OpId): any[] +``` + + +Gets all operations in a specific change. + +**Parameters:** + +- `id` - Operation ID + +**Returns:** Array of operations + +**Example:** + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "hello"); +const changes = doc.getAllChanges(); +const ops = doc.getOpsInChange(changes[0].id); +``` + + + + +```typescript no_run +getPendingTxnLength(): number +``` + + +Gets the number of pending operations in the current transaction. + +**Returns:** Number of pending operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "x"); +console.log(doc.getPendingTxnLength()); +doc.commit(); +``` + + + +### Import/Export Utilities + + +```typescript no_run +decodeImportBlobMeta(blob: Uint8Array, check_checksum: boolean): ImportBlobMetadata +``` + + +Decodes metadata from an import blob. + +**Parameters:** + +- `blob` - Binary data to decode +- `check_checksum` - Whether to verify checksum + +**Returns:** Import blob metadata + +**Example:** + +```ts threeslash +import { LoroDoc, decodeImportBlobMeta } from "loro-crdt"; + +const doc = new LoroDoc(); +const updates = doc.export({ mode: "update" }); +const meta = decodeImportBlobMeta(updates, true); +``` + + + + +```typescript no_run +redactJsonUpdates(json: string | JsonSchema, version_range: any): JsonSchema +``` + + +Redacts JSON updates within a specified version range. + +Use this to safely remove accidentally leaked sensitive content from history while preserving structure. See [Tips: Redaction](/docs/tutorial/tips). + +**Parameters:** + +- `json` - JSON updates to redact +- `version_range` - Version range for redaction + +**Returns:** Redacted JSON schema + +**Example:** + +```ts threeslash +import { LoroDoc, redactJsonUpdates } from "loro-crdt"; + +const doc = new LoroDoc(); +const json = doc.exportJsonUpdates(); +const redacted = redactJsonUpdates(json, { [doc.peerIdStr]: [0, 999999] }); +``` + + + +--- + +## Container Types + +Common CRDT containers for modeling JSON-like structures. See [Choosing CRDT Types](/docs/concepts/choose_crdt_type) and [Composing CRDTs](/docs/tutorial/composition) for when to use each and how to nest them. + +### LoroText + +A rich text container supporting collaborative text editing with formatting. Supports overlapping marks (bold, italic, links) and +stable cursors. The merge semantics avoid interleaving artifacts under concurrency (Fugue + Eg-walker ideas); you use simple index +APIs and Loro handles index transformation. +See [Text](/docs/tutorial/text), [Eg-walker](/docs/advanced/event_graph_walker), and the rich text blog: https://loro.dev/blog/loro-richtext + +**⚠️ Critical: UTF-16 String Encoding** + +LoroText uses **UTF-16** encoding, matching JavaScript's native string encoding: + +- All standard methods (`insert()`, `delete()`, `mark()`, `slice()`, `charAt()`) use UTF-16 code unit indices +- `length` returns UTF-16 code units (same as JavaScript `string.length`) +- Use `insertUtf8()` and `deleteUtf8()` for UTF-8 byte-based operations when integrating with UTF-8 systems + +**⚠️ Common Pitfalls:** + +1. **Index Misalignment**: UTF-16 indices differ from visual character count +2. **Performance**: Cursor queries on deleted positions require history traversal - in that case, it will return a refreshed Cursor object that does not point to the deleted text + +**Example with emoji:** + +```typescript no_run +const text = doc.getText("text"); +text.insert(0, "Hello 😀 World"); +console.log(text.length); // 13 (emoji counts as 2) +console.log(text.toString()[6]); // ⚠️ Invalid - splits the emoji +text.delete(6, 2); // ✅ Correct - deletes entire emoji +text.delete(6, 1); // ❌ Wrong - corrupts the emoji + +// Safe iteration +text.iter((char) => { + console.log(char); // Each character handled correctly + return true; +}); +``` + +**📝 Text vs String in Maps:** + +- Use `LoroText` for collaborative text editing where all concurrent edits must be preserved +- Use regular strings in `LoroMap` for atomic values (URLs, IDs, hashes) where Last-Write-Wins is preferred +- Example: URLs should be strings in maps, not LoroText. Otherwise, the automatically merged result may be an invalid URL + + + ```typescript no_run insert(index: number, text: string): void ``` + + + +Inserts text at the specified position using UTF-16 code unit indices (same as JavaScript string indices). + +**Parameters:** + +- `index` - UTF-16 code unit position to insert at (0-based, same as JavaScript string index) +- `text` - Text to insert + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// Usage example: +text.insert(0, "Hello "); +text.insert(6, "World"); +``` + + + + + ```typescript no_run delete(index: number, len: number): void ``` + + + +Deletes text from the specified position using UTF-16 code units. + +**Parameters:** + +- `index` - Starting UTF-16 code unit position (same as JavaScript string index) +- `len` - Number of UTF-16 code units to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello 😀 World"); +text.delete(6, 2); // Delete emoji (2 UTF-16 units) +text.delete(5, 1); // Delete space before World +``` + + + + +```typescript no_run +mark(range: { start: number, end: number }, key: string, value: Value): void +``` + + + +Applies formatting to a text range. Marks can be configured to expand/stop at edges via configTextStyle(); see [Text](/docs/tutorial/text) for mark behavior. + +**Parameters:** + +- `range` - The range to format +- `key` - Style attribute name +- `value` - Style value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +doc.configTextStyle({ bold: { expand: "after" } }); +text.mark({ start: 0, end: 5 }, "bold", true); +``` + + + + +```typescript no_run +unmark(range: { start: number, end: number }, key: string): void +``` + + +Removes formatting from a text range. For how conflicting edits on marks resolve, see [Text](/docs/tutorial/text). + +**Parameters:** + +- `range` - The range to unformat +- `key` - Style attribute to remove + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +text.mark({ start: 0, end: 5 }, "bold", true); +text.unmark({ start: 0, end: 5 }, "bold"); +``` + + + + +```typescript no_run +toDelta(): Delta[] +``` + + +Converts text to Delta format (Quill-compatible). + +**Returns:** Array of Delta operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +text.mark({ start: 0, end: 5 }, "bold", true); +const delta = text.toDelta(); +// [{ insert: "Hello", attributes: { bold: true } }, { insert: " World" }] +``` + + + + +```typescript no_run +sliceDelta(start: number, end: number): Delta[] +``` + + +Returns a Quill-style Delta for a subsection of the text, using UTF-16 indices. Useful for copying a styled span. Use `sliceDeltaUtf8` if you need UTF-8 byte offsets instead. + +**Parameters:** + +- `start` - Start UTF-16 code unit index (inclusive) +- `end` - End UTF-16 code unit index (exclusive) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + comment: { expand: "none" }, +}); +const text = doc.getText("text"); + +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +text.mark({ start: 6, end: 11 }, "comment", "greeting"); + +const snippet = text.sliceDelta(1, 8); +expect(snippet).toStrictEqual([ + { insert: "ello", attributes: { bold: true } }, + { insert: " " }, + { insert: "Wo", attributes: { comment: "greeting" } }, +]); +``` + + + + +```typescript no_run +sliceDeltaUtf8(start: number, end: number): Delta[] +``` + + +Returns a Quill-style Delta for a subsection of the text using **UTF-8 byte offsets**. Choose this when your offsets come from UTF-8 encoded buffers. + +**Parameters:** + +- `start` - Start byte offset (inclusive) +- `end` - End byte offset (exclusive) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hi 👋"); + +const enc = new TextEncoder(); +const start = enc.encode("Hi ").length; // 3 bytes +const end = enc.encode("Hi 👋").length; // 7 bytes + +const delta = text.sliceDeltaUtf8(start, end); +expect(delta).toStrictEqual([{ insert: "👋" }]); +``` + + + + +```typescript no_run +applyDelta(delta: Delta[]): void +``` + + +Applies Delta operations to the text. + +**Parameters:** + +- `delta` - Array of Delta operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.applyDelta([ + { insert: "Hello", attributes: { bold: true } }, + { insert: " World" }, +]); +``` + + + + +```typescript no_run +update(text: string, options?: { timeoutMs?: number; useRefinedDiff?: boolean }): void +``` + + +Updates the current text to the target text using Myers' diff algorithm. + +**Parameters:** + +- `text` - New text content +- `options` - Update options + - `timeoutMs` - Optional timeout for the diff computation + - `useRefinedDiff` - Use refined diff for better quality on long texts + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Hello"); +text.update("Hello World", { timeoutMs: 100 }); +``` + + + + +```typescript no_run +updateByLine(text: string, options?: { timeoutMs?: number; useRefinedDiff?: boolean }): void +``` + + +Line-based update that's faster for large texts (less precise than `update`). + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Line A\nLine C"); +text.updateByLine("Line A\nLine B\nLine C"); +``` + + + + +```typescript no_run +getCursor(pos: number, side?: Side): Cursor | undefined +``` + + +Gets a stable cursor position that survives edits. + +**Parameters:** + +- `pos` - Position in the text +- `side` - Cursor affinity (-1, 0, or 1) + +**Returns:** Cursor object or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +const cursor = text.getCursor(5); +// Cursor remains valid even after edits +``` + + + + +```typescript no_run +toString(): string +``` + + +Converts to plain text string. + +**Returns:** Plain text content + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +const plainText = text.toString(); +``` + + + + +```typescript no_run +charAt(pos: number): string +``` + + +Gets the character at a specific UTF-16 code unit position. + +**Parameters:** + +- `pos` - UTF-16 code unit position + +**Returns:** Character at position + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); +const char = text.charAt(1); // "e" +``` + + + + +```typescript no_run +slice(start: number, end: number): string +``` + + +Extracts a section of the text using UTF-16 code unit positions. + +**Parameters:** + +- `start` - Start UTF-16 code unit index +- `end` - End UTF-16 code unit index (exclusive) + +**Returns:** Sliced text + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello 😀 World"); + +const slice1 = text.slice(0, 5); // "Hello" +const slice2 = text.slice(6, 8); // "😀" (emoji spans 6-8) +const slice3 = text.slice(9, 14); // "World" +``` + + + + +```typescript no_run +splice(pos: number, len: number, s: string): string +``` + + +Replaces text at a position with new content. + +**Parameters:** + +- `pos` - Start position +- `len` - Length to delete +- `s` - String to insert + +**Returns:** Deleted text + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Usage example: +const deleted = text.splice(6, 5, "Loro"); // returns "World" +``` + + + + +```typescript no_run +push(s: string): void +``` + + +Appends text to the end of the document. + +**Parameters:** + +- `s` - String to append + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.push("Hello"); +text.push(" World"); +``` + + + + +```typescript no_run +iter(callback: (char: string) => boolean): void +``` + + +Iterates over each character in the text. + +**Parameters:** + +- `callback` - Function called for each character. Return false to stop iteration. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +text.iter((char) => { + console.log(char); + return true; // continue iteration +}); +``` + + + + +```typescript no_run +insertUtf8(index: number, content: string): void +``` + + +Inserts text at a UTF-8 byte index position. + +**Parameters:** + +- `index` - UTF-8 byte index +- `content` - Text to insert + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insertUtf8(0, "Hello"); +``` + + + + +```typescript no_run +deleteUtf8(index: number, len: number): void +``` + + +Deletes text at a UTF-8 byte index position. + +**Parameters:** + +- `index` - UTF-8 byte index +- `len` - Number of UTF-8 bytes to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Usage example: +text.deleteUtf8(6, 5); // Delete "World" +``` + + + + +```typescript no_run +getEditorOf(pos: number): PeerID | undefined +``` + + +Gets the peer ID of who last edited the character at a position. + +**Parameters:** + +- `pos` - Character position + +**Returns:** PeerID of last editor or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const editor = text.getEditorOf(0); +``` + + + + +```typescript no_run +kind(): "Text" +``` + + +Returns the container type. + +**Returns:** "Text" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +const type = text.kind(); // "Text" +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this text is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const text = list.insertContainer(0, doc.getText("nested")); + +// Usage example: +const parent = text.parent(); // Returns the list +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const text = new LoroText(); + +const attached = text.isAttached(); // false until attached to doc +``` + + + + +```typescript no_run +getAttached(): LoroText | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const text = new LoroText(); + +const attached = text.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +const deleted = text.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): string +``` + + +Gets the text content without marks. + +**Returns:** Plain text string + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const value = text.getShallowValue(); // "Hello" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the text to JSON representation. + +**Returns:** JSON value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const json = text.toJSON(); // "Hello" +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +const containerId = text.id; +``` + + + + +```typescript no_run +readonly length: number +``` + + +Gets the length of the text in UTF-16 code units (same as JavaScript's `string.length`). + +**⚠️ Important:** Emoji and other characters outside the Basic Multilingual Plane count as 2 UTF-16 units. This affects all index-based operations: + +```typescript no_run +text.insert(0, "👨‍👩‍👧‍👦"); // Family emoji +console.log(text.length); // 11 (not 1!) - complex emoji with ZWJ sequences +``` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); +console.log(text.length); // 5 + +text.insert(5, " 😀"); +console.log(text.length); // 8 (space + emoji which counts as 2) +``` + + + +### LoroList + +An ordered list container for collaborative arrays. Uses index-based APIs; under concurrency, Loro transforms indices +by replaying only the necessary portion of history (Eg-walker-inspired). See [List and Movable List](/docs/tutorial/list), +[Choosing CRDT Types](/docs/concepts/choose_crdt_type), and [Eg-walker](/docs/advanced/event_graph_walker). + +**⚠️ Important: List vs Map for Coordinates** + +```typescript no_run +// ❌ WRONG - Don't use List for coordinates +const coord = doc.getList("coord"); +coord.push(10); // x +coord.push(20); // y +// Concurrent updates can create [10, 20a, 20b] instead of [10, 20] + +// ✅ CORRECT - Use Map for coordinates +const coord = doc.getMap("coord"); +coord.set("x", 10); +coord.set("y", 20); +// Concurrent updates properly merge to {x: 10, y: 20} +``` + + +```typescript no_run +insert(pos: number, value: Value | Container): void +``` + + +Inserts a value at the specified position. + +**Parameters:** + +- `pos` - Insert position +- `value` - Value to insert + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.insert(0, "First"); +list.insert(1, { type: "object" }); +``` + + + + +```typescript no_run +insertContainer(pos: number, container: T): T +``` + + +Inserts a new container at the position. + +**Parameters:** + +- `pos` - Insert position +- `container` - Container instance + +**Returns:** The inserted container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const subText = list.insertContainer(0, new LoroText()); +subText.insert(0, "Nested text"); +``` + + + + +```typescript no_run +delete(pos: number, len: number): void +``` + + +Deletes elements from the list. + +**Parameters:** + +- `pos` - Starting position +- `len` - Number of elements to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a"); +list.push("b"); +list.push("c"); +list.push("d"); + +list.delete(1, 2); // Delete 2 elements starting at index 1 +``` + + + + +```typescript no_run +push(value: Value | Container): void +``` + + +Appends a value to the end of the list. + +**Parameters:** + +- `value` - Value to append + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("Last item"); +``` + + + + +```typescript no_run +getIdAt(pos: number): { peer: PeerID, counter: number } | undefined +``` + + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +list.insert(0, 1); +const id0 = list.getIdAt(0); + +```` + + + +```typescript no_run +pushContainer(container: T): T +```` + + + +Appends a container to the end of the list. + +**Parameters:** + +- `container` - Container to append + +**Returns:** The appended container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroMap } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const map = list.pushContainer(new LoroMap()); +map.set("key", "value"); +``` + + + + +```typescript no_run +pop(): Value | Container | undefined +``` + + +Removes and returns the last element. + +**Returns:** The removed element or undefined + +**Example:** + +```ts no_run threeslash +import { LoroList } from "loro-crdt"; +declare const list: LoroList; +// ---cut--- +const lastItem = list.pop(); +``` + + + + +```typescript no_run +get(index: number): Value | Container | undefined +``` + + +Gets the value at the specified index. + +**Parameters:** + +- `index` - Element index + +**Returns:** The value or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const list = doc.getList("items"); +list.push("first", "second"); + +const item = list.get(0); // "first" +``` + + + + +```typescript no_run +getCursor(pos: number, side?: Side): Cursor | undefined +``` + + +Gets a stable cursor for the position. + +**Parameters:** + +- `pos` - Position in the list +- `side` - Cursor affinity + +**Returns:** Cursor object or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a", "b", "c"); + +const cursor = list.getCursor(2); +``` + + + + +```typescript no_run +toArray(): (Value | Container)[] +``` + + +Converts the list to a JavaScript array. + +**Returns:** Array of values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a", "b", "c"); +const array = list.toArray(); +``` + + + + +```typescript no_run +clear(): void +``` + + +Removes all elements from the list. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a", "b", "c"); +list.clear(); +``` + + + + +```typescript no_run +length: number +``` + + +Gets the number of elements in the list. + +**Returns:** List length + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a"); +list.push("b"); +list.push("c"); +console.log(`List has ${list.length} items`); +``` + + + + +```typescript no_run +kind(): "List" +``` + + +Returns the container type. + +**Returns:** "List" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +const type = list.kind(); // "List" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the list to JSON representation. + +**Returns:** JSON array + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push(1, 2, 3); + +// Usage example: +const json = list.toJSON(); // [1, 2, 3] +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this list is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +const list = map.setContainer("nested", doc.getList("list")); + +// Usage example: +const parent = list.parent(); // Returns the map +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroList } from "loro-crdt"; +const doc = new LoroDoc(); +const list = new LoroList(); + +const attached = list.isAttached(); // false until attached to doc +``` + + + + +```typescript no_run +getAttached(): LoroList | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroList } from "loro-crdt"; +const doc = new LoroDoc(); +const list = new LoroList(); + +const attached = list.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +const deleted = list.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): Value[] +``` + + +Gets the list values with sub-containers as IDs. + +**Returns:** Array of values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push(1, 2); + +// Usage example: +const values = list.getShallowValue(); // [1, 2] +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +const containerId = list.id; +``` + + + +### LoroMap + +A key-value map container for collaborative objects. See [Map](/docs/tutorial/map). + + +```typescript no_run +set(key: string, value: Value | Container): void +``` + + +Sets a key-value pair. + +Note: Setting a key to the same value is a no-op (no operation recorded). See [Map basics](/docs/tutorial/map). + +**Parameters:** + +- `key` - The key +- `value` - The value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.set("name", "Alice"); +map.set("age", 30); +``` + + + + +```typescript no_run +setContainer(key: string, container: T): T +``` + + +Sets a container as the value for a key. + +**⚠️ Pitfall:** Concurrent regular child container creation at the same key on a LoroMap from multiple peers causes overwrites (different container IDs cannot auto-merge). If the child should be shared by logical key, use the `ensureMergeable*` methods instead. See [Container initialization](/docs/tutorial/tips). + +**Parameters:** + +- `key` - The key +- `container` - Container instance + +**Returns:** The set container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroList } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const list = map.setContainer("items", new LoroList()); +list.push("item1"); +``` + + + + +```typescript no_run +ensureMergeableText(key: string): LoroText +``` + + +Ensures that `key` points to a mergeable Text child container. Peers that call this method concurrently with the same parent Map and key will address the same logical Text container instead of creating competing child container IDs. + +Use this for lazily-created text fields that should behave like one shared document for all peers. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Text container + +**Example:** + +```ts no_run +const body = doc.getMap("note").ensureMergeableText("body"); +body.insert(0, "Hello"); +``` + + + + +```typescript no_run +ensureMergeableMap(key: string): LoroMap +``` + + +Ensures that `key` points to a mergeable Map child container. Use this when a nested record, profile, settings object, or migration-created child Map may be initialized by multiple peers. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Map container + +**Example:** + +```ts no_run +const record = doc.getMap("records").ensureMergeableMap(recordId); +record.set("updated", true); +``` + + + + +```typescript no_run +ensureMergeableList(key: string): LoroList +``` + + +Ensures that `key` points to a mergeable List child container. Use this for dynamic child lists, such as one list per day, where concurrent first creation should produce one shared List. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable List container + +**Example:** + +```ts no_run +const entries = doc.getMap("days").ensureMergeableList("2026-06-08"); +entries.insert(0, "meeting notes"); +``` + + + + +```typescript no_run +ensureMergeableMovableList(key: string): LoroMovableList +``` + + +Ensures that `key` points to a mergeable MovableList child container. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable MovableList container + + + + +```typescript no_run +ensureMergeableTree(key: string): LoroTree +``` + + +Ensures that `key` points to a mergeable Tree child container. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Tree container + + + + +```typescript no_run +ensureMergeableCounter(key: string): LoroCounter +``` + + +Ensures that `key` points to a mergeable Counter child container. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Counter container + + + + +```typescript no_run +get(key: string): Value | Container | undefined +``` + + +Gets the value for a key. + +**Parameters:** + +- `key` - The key + +**Returns:** The value or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const name = map.get("name"); +``` + + + + +```typescript no_run +/** + * @deprecated Prefer ensureMergeableText/Map/List/... for lazily-created Map child containers. + */ +getOrCreateContainer(key: string, container: T): T +``` + + +**Deprecated:** Prefer the `ensureMergeable*` methods for Map child containers that may be created lazily or concurrently by multiple peers. + +Gets an existing container or creates a new regular child container. + +**⚠️ Pitfall:** Parallel container creation for the same key across peers creates different child container IDs and can cause overwrites. This method is kept for compatibility and for cases where you explicitly want a regular child container. For shared dynamic children, use `ensureMergeableText`, `ensureMergeableMap`, `ensureMergeableList`, `ensureMergeableMovableList`, `ensureMergeableTree`, or `ensureMergeableCounter`. See [Container initialization](/docs/tutorial/tips). + +**Parameters:** + +- `key` - The key +- `container` - Container to create if not exists + +**Returns:** The container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const text = map.getOrCreateContainer("description", new LoroText()); +``` + + + + +```typescript no_run +delete(key: string): void +``` + + +Removes a key-value pair. + +**Parameters:** + +- `key` - The key to remove + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.delete("obsoleteKey"); +``` + + + + +```typescript no_run +clear(): void +``` + + +Removes all key-value pairs. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.clear(); +``` + + + + +```typescript no_run +keys(): string[] +``` + + +Gets all keys in the map. + +**Returns:** Array of keys + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const allKeys = map.keys(); +``` + + + + +```typescript no_run +values(): (Value | Container)[] +``` + + +Gets all values in the map. + +**Returns:** Array of values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const allValues = map.values(); +``` + + + + +```typescript no_run +entries(): [string, Value | Container][] +``` + + +Gets all key-value pairs. + +**Returns:** Array of entries + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +for (const [key, value] of map.entries()) { + console.log(`${key}: ${value}`); +} +``` + + + + +```typescript no_run +getLastEditor(key: string): PeerID | undefined +``` + + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.set("k", 1); +doc.commit(); +const who = map.getLastEditor("k"); +// who = doc.peerIdStr + +```` + + + +```typescript no_run +size: number +```` + + + +Gets the number of key-value pairs. + +**Returns:** Map size + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +console.log(`Map has ${map.size} entries`); +``` + + + + +```typescript no_run +kind(): "Map" +``` + + +Returns the container type. + +**Returns:** "Map" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const type = map.kind(); // "Map" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the map to JSON representation. + +**Returns:** JSON object + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("name", "Alice"); + +// Usage example: +const json = map.toJSON(); // { name: "Alice" } +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this map is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const map = list.insertContainer(0, doc.getMap("nested")); + +// Usage example: +const parent = map.parent(); // Returns the list +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroMap } from "loro-crdt"; +const doc = new LoroDoc(); +const map = new LoroMap(); + +const attached = map.isAttached(); // false until attached to doc +``` + + + + +```typescript no_run +getAttached(): LoroMap | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroMap } from "loro-crdt"; +const doc = new LoroDoc(); +const map = new LoroMap(); + +const attached = map.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const deleted = map.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): Record +``` + + +Gets the map values with sub-containers as IDs. + +**Returns:** Object with values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("key", "value"); + +// Usage example: +const values = map.getShallowValue(); // { key: "value" } +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const containerId = map.id; +``` + + + +### LoroTree + +A hierarchical tree container for nested structures. Supports moving subtrees while handling concurrent edits. See [Tree](/docs/tutorial/tree). + +**⚠️ Important Tree Operation Notes:** + +- **Concurrent moves can create cycles**: Loro detects and prevents these automatically +- **Fractional indexing**: Has interleaving issues but maintains relative ordering +- **Don't disable fractional index** if you need siblings to be sorted. See [Tree](/docs/tutorial/tree). + + +```typescript no_run +createNode(parent?: TreeID, index?: number): LoroTreeNode +``` + + +Creates a new tree node. + +**Parameters:** + +- `parent` - Parent node ID (optional, creates root if omitted) +- `index` - Position among siblings (optional) + +**Returns:** The new node's handler + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const root = tree.createNode(); +const child = root.createNode(0); +``` + + + + +```typescript no_run +move(target: TreeID, parent?: TreeID, index?: number): void +``` + + +Moves a node to a new position. + +**Parameters:** + +- `target` - Node to move +- `parent` - New parent (undefined for root) +- `index` - Position among siblings + +**Example:** + +```typescript no_run threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +declare const nodeId: TreeID; +declare const newParentId: TreeID; + +// Usage example: +tree.move(nodeId, newParentId, 0); +``` + + + + +```typescript no_run +delete(target: TreeID): void +``` + + +Deletes a node and its descendants. + +**Parameters:** + +- `target` - Node to delete + +**Example:** + +```ts threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const nodeId = node.id; + +// Usage example: +tree.delete(nodeId); +``` + + + + +```typescript no_run +getNodeByID(id: TreeID): LoroTreeNode | undefined +``` + + +Gets a node handler by its ID. + +**Parameters:** + +- `id` - Node ID + +**Returns:** Node handler or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const _node = tree.createNode(); +const nodeId = _node.id; + +// Usage example: +const node = tree.getNodeByID(nodeId); +if (node) { + node.data.set("label", "New Label"); +} +``` + + + + +```typescript no_run +nodes(): LoroTreeNode[] +``` + + +Gets all nodes in the tree. + +**Returns:** Array of all nodes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const allNodes = tree.nodes(); +``` + + + + +```typescript no_run +roots(): LoroTreeNode[] +``` + + +Gets all root nodes. + +**Returns:** Array of root nodes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const rootNodes = tree.roots(); +``` + + + + +```typescript no_run +has(target: TreeID): boolean +``` + + +Checks if a node exists. + +**Parameters:** + +- `target` - Node ID to check + +**Returns:** Boolean indicating existence + +**Example:** + +```typescript no_run threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +declare const nodeId: TreeID; + +// Usage example: +if (tree.has(nodeId)) { + console.log("Node exists"); +} +``` + + + + +```typescript no_run +isNodeDeleted(target: TreeID): boolean +``` + + +Checks if a node has been deleted. + +**Parameters:** + +- `target` - Node ID to check + +**Returns:** Boolean indicating deletion status + +**Example:** + +```typescript no_run threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +declare const nodeId: TreeID; + +// Usage example: +if (tree.isNodeDeleted(nodeId)) { + console.log("Node was deleted"); +} +``` + + + +```typescript no_run +enableFractionalIndex(jitter: number): void +``` + + +Enables fractional indexing for better concurrent move performance. + +**Parameters:** + +- `jitter` - Jitter amount for fractional indices + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +tree.enableFractionalIndex(0.001); +``` + + + + +```typescript no_run +disableFractionalIndex(): void +``` + + +Disables fractional indexing. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +tree.disableFractionalIndex(); +``` + + + + +```typescript no_run +isFractionalIndexEnabled(): boolean +``` + + +Checks if fractional indexing is enabled. + +**Returns:** True if enabled + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const enabled = tree.isFractionalIndexEnabled(); +``` + + + + +```typescript no_run +kind(): "Tree" +``` + + +Returns the container type. + +**Returns:** "Tree" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const type = tree.kind(); // "Tree" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the tree to JSON representation. + +**Returns:** JSON tree structure + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const json = tree.toJSON(); +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this tree is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +const tree = map.setContainer("tree", doc.getTree("nested")); + +// Usage example: +const parent = tree.parent(); +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTree } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = new LoroTree(); + +const attached = tree.isAttached(); +``` + + + + +```typescript no_run +getAttached(): LoroTree | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTree } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = new LoroTree(); + +const attached = tree.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const deleted = tree.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): TreeNodeShallowValue[] +``` + + +Gets the tree values with sub-containers as IDs. + +**Returns:** Array of tree node values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const values = tree.getShallowValue(); +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const containerId = tree.id; +``` + + + +### LoroTreeNode + +Represents a single node in the tree. + + +```typescript no_run +data: LoroMap +``` + + +A map container for storing node metadata. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +node.data.set("title", "Node Title"); +node.data.set("expanded", true); +``` + + + + +```typescript no_run +createNode(index?: number): LoroTreeNode +``` + + +Creates a child node. + +**Parameters:** + +- `index` - Position among siblings + +**Returns:** New node's handler + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const childId = node.createNode(0); +``` + + + + +```typescript no_run +move(parent?: LoroTreeNode, index?: number): void +``` + + +Moves this node to a new parent. + +**Parameters:** + +- `parent` - New parent node +- `index` - Position among siblings + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTreeNode } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const parent = tree.createNode(); + +// Usage example: +node.move(parent, 0); +``` + + + + +```typescript no_run +moveAfter(target: LoroTreeNode): void +``` + + +Moves this node after a sibling. + +**Parameters:** + +- `target` - Sibling node + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTreeNode } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const sibling = tree.createNode(); + +// Usage example: +node.moveAfter(sibling); +``` + + + + +```typescript no_run +moveBefore(target: LoroTreeNode): void +``` + + +Moves this node before a sibling. + +**Parameters:** + +- `target` - Sibling node + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTreeNode } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const sibling = tree.createNode(); + +// Usage example: +node.moveBefore(sibling); +``` + + + + +```typescript no_run +parent(): LoroTreeNode | undefined +``` + + +Gets the parent node. + +**Returns:** Parent node or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +const parentNode = node.parent(); +``` + + + + +```typescript no_run +children(): LoroTreeNode[] +``` + + +Gets all child nodes. + +**Returns:** Array of children + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +const childNodes = node.children(); +``` + + + + +```typescript no_run +index(): number | undefined +``` + + +Gets the position among siblings. + +**Returns:** Index or undefined if root + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +const position = node.index(); +``` + + + + +```typescript no_run +fractionalIndex(): string | undefined +``` + + +Returns the node's fractional index used to sort siblings deterministically. +It is a hex string representation of the Fractional Index and is stable for ordering. +Returns `undefined` for the root node. Note: the tree must be attached to the document. + +**Returns:** Hex string or `undefined` for root + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const parent = tree.createNode(); +const a = parent.createNode(0); +const b = parent.createNode(1); + +const aFi = a.fractionalIndex(); +const bFi = b.fractionalIndex(); +// aFi < bFi, because b is inserted after a +``` + + + + +```typescript no_run +creationId(): { peer: PeerID, counter: number } +``` + + +Returns the OpID that created this node. + +**Returns:** `{ peer: PeerID, counter: number }` creation identifier + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +const { peer, counter } = node.creationId(); +``` + + + + +```typescript no_run +creator(): PeerID +``` + + +Returns the peer ID that created this node (equivalent to `creationId().peer`). + +**Returns:** `PeerID` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +const author = node.creator(); +// author == doc.peerIdStr +``` + + + + +```typescript no_run +getLastMoveId(): { peer: PeerID, counter: number } | undefined +``` + + +Returns the OpID of the most recent move operation for this node, or `undefined` if the node has never been moved. + +**Returns:** Creation/move OpID or `undefined` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +const lastMove = node.getLastMoveId(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if this node has been deleted. + +**Returns:** Boolean deletion status + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +if (node.isDeleted()) { + console.log("Node is deleted"); +} +``` + + + +### LoroCounter + +A counter CRDT for collaborative numeric values. + + +```typescript no_run +increment(value: number): void +``` + + +Increments the counter. + +**Parameters:** + +- `value` - Amount to increment (default: 1) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); + +counter.increment(5); // +5 +``` + + + + +```typescript no_run +decrement(value: number): void +``` + + +Decrements the counter. + +**Parameters:** + +- `value` - Amount to decrement (default: 1) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); + +counter.decrement(3); // -3 +``` + + + + +```typescript no_run +value: number +``` + + +Gets the current counter value. + +**Returns:** Current numeric value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); + +console.log(`Counter value: ${counter.value}`); +``` + + + +### LoroMovableList + +A list optimized for move operations. Designed for frequent reordering (drag-and-drop) with good behavior under +concurrent moves (concurrent moves resolve to one final position). See [List and Movable List](/docs/tutorial/list). + +**📝 MovableList vs List:** + +- **Use MovableList** for: Drag-and-drop UIs, sortable lists, kanban boards +- **Use List** for: scenarios where the list items don't need to be moved +- **Key difference**: MovableList handles concurrent moves better (no duplicates) and supports set operations, List is more efficient in general. + + +```typescript no_run +move(from: number, to: number): void +``` + + +Moves an element from one position to another. + +**Parameters:** + +- `from` - Source index +- `to` - Target index + +**Example:** + +```typescript +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const movableList = doc.getMovableList("list"); +movableList.push("a"); +movableList.push("b"); +movableList.push("c"); +movableList.push("d"); +movableList.push("e"); + +movableList.move(0, 3); // Move first element to fourth position +``` + + + + +```typescript no_run +set(pos: number, value: Value | Container): void +``` + + +Replaces the value at a position. + +**Parameters:** + +- `pos` - Position to update +- `value` - New value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const movableList = doc.getMovableList("list"); +movableList.push("a", "b", "c"); + +movableList.set(0, "Updated value"); +``` + + + + +```typescript no_run +setContainer(pos: number, container: T): T +``` + + +Replaces the value with a container. + +**Parameters:** + +- `pos` - Position to update +- `container` - New container + +**Returns:** The set container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const movableList = doc.getMovableList("list"); +movableList.push("placeholder"); + +const text = movableList.setContainer(0, new LoroText()); +``` + + + +## Synchronization + +Import/export updates over any transport and choose the right encoding for speed and size. See [Sync Tutorial](/docs/tutorial/sync) and [Encoding & Export Modes](/docs/tutorial/encoding). + +- **Import order**: Loro handles out-of-order updates automatically +- **Auto-commit**: Import and export operations trigger automatic commits + +### Import/Export Patterns + +#### Basic Synchronization + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +// Peer A: Export updates +const doc1 = new LoroDoc(); +doc1.getText("text").insert(0, "Hello"); +const updates = doc1.export({ mode: "update" }); + +// Peer B: Import updates +const doc2 = new LoroDoc(); +doc2.import(updates); +// now doc2.getText("text").toString() === "Hello" +``` + +#### Continuous Sync + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); + +// Usage example: +// Set up bidirectional sync +doc1.subscribeLocalUpdates((updates) => { + doc2.import(updates); +}); + +doc2.subscribeLocalUpdates((updates) => { + doc1.import(updates); +}); +``` + +Performance tips: + +- Prefer `mode: "update"` with a `VersionVector` to sync incrementally. +- Use `mode: "shallow-snapshot"` when you only need current state; it strips history for faster import/load. +- Loro’s LSM-based encoding and Eg-walker-inspired merge keep import/export fast, even for large histories. + See [Encoding](/docs/tutorial/encoding) and v1.0 performance notes: https://loro.dev/blog/v1.0 + +#### Network Sync with WebSocket + +```ts no_run threeslash +import { LoroDoc } from "loro-crdt"; + +// Assume we have: +declare const ws: { + send: (data: Uint8Array) => void; + on: (event: string, handler: (data: any) => void) => void; +}; + +// Client side +const doc = new LoroDoc(); + +// Send local updates to server +doc.subscribeLocalUpdates((updates) => { + ws.send(updates); +}); + +// Receive updates from server +ws.on("message", (data) => { + doc.import(new Uint8Array(data)); +}); +``` + +### Shallow Snapshots + +Shallow snapshots allow for efficient storage by garbage collecting deleted operations. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Create shallow snapshot +const frontiers = doc.frontiers(); +const shallowSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: frontiers, +}); + +// Import shallow snapshot +const newDoc = new LoroDoc(); +newDoc.import(shallowSnapshot); +``` + +--- + +## Version Control + +### Time Travel + +Navigate through document history: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Save current version +const v1 = doc.frontiers(); + +// Make changes +doc.getText("text").insert(0, "New text"); + +// Save new version +const v2 = doc.frontiers(); + +// Travel back +doc.checkout(v1); +console.log(doc.getText("text").toString()); // Original text + +// Travel forward +doc.checkout(v2); +console.log(doc.getText("text").toString()); // New text + +// Return to latest +doc.checkoutToLatest(); +``` + +### Forking + +Create document branches: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Fork at current state +const fork1 = doc.fork(); +fork1.getText("text").insert(0, "Fork 1 changes"); + +// Fork at specific version +const historicalVersion = doc.frontiers(); +const fork2 = doc.forkAt(historicalVersion); +fork2.getText("text").insert(0, "Fork 2 changes"); + +// Original document remains unchanged +``` + +### Version Vectors + +Track document versions: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const vv = doc.version(); +console.log(`Document has ${vv.length()} peers`); +``` + +--- + +## Events & Subscriptions + +### Event Structure + +```typescript no_run +interface LoroEventBatch { + by: "local" | "import" | "checkout"; + origin?: string; + currentTarget?: ContainerID; + events: LoroEvent[]; + from: Frontiers; + to: Frontiers; +} + +interface LoroEvent { + target: ContainerID; + diff: Diff; + path: Path; +} +``` + +### Diff Types + +Different containers produce different diff types: + +#### TextDiff + + +```typescript no_run +type TextDiff = { + type: "text" + diff: Delta[] +} +``` +Represents changes to text content using Delta format. + +**Properties:** + +- `type` - Always "text" for text diffs +- `diff` - Array of Delta operations (insert, delete, retain) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// Example +text.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "text") { + event.diff.diff.forEach((delta) => { + if (delta.insert) { + console.log(`Inserted: "${delta.insert}"`); + } + if (delta.delete) { + console.log(`Deleted ${delta.delete} characters`); + } + }); + } + } +}); +``` + + + +#### ListDiff + + +```typescript no_run +type ListDiff = { + type: "list" + diff: Delta<(Value | Container)[]>[] +} +``` +Represents changes to list content using Delta format. + +**Properties:** + +- `type` - Always "list" for list diffs +- `diff` - Array of Delta operations on list items + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +// Example +list.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "list") { + event.diff.diff.forEach((delta) => { + if (delta.insert) { + console.log(`Inserted items:`, delta.insert); + } + }); + } + } +}); +``` + + + +#### MapDiff + + +```typescript no_run +type MapDiff = { + type: "map" + updated: Record +} +``` +Represents changes to map content. + +**Properties:** + +- `type` - Always "map" for map diffs +- `updated` - Record of key-value changes (undefined means deleted) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Example +map.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "map") { + Object.entries(event.diff.updated).forEach(([key, value]) => { + if (value === undefined) { + console.log(`Deleted key: ${key}`); + } else { + console.log(`Updated key: ${key} = ${value}`); + } + }); + } + } +}); +``` + + + +#### TreeDiff + + +```typescript no_run +type TreeDiff = { + type: "tree" + diff: TreeDiffItem[] +} + +type TreeDiffItem = +| { +target: TreeID +action: "create" +parent: TreeID | undefined +index: number +fractionalIndex: string +} +| { +target: TreeID +action: "delete" +oldParent: TreeID | undefined +oldIndex: number +} +| { +target: TreeID +action: "move" +parent: TreeID | undefined +index: number +fractionalIndex: string +oldParent: TreeID | undefined +oldIndex: number +} + +```` +Represents changes to tree structure. + +**Properties:** +- `type` - Always "tree" for tree diffs +- `diff` - Array of TreeDiffItem operations (create, delete, move) + +**TreeDiffItem Actions:** +- `create` - Node creation with parent and position +- `delete` - Node deletion with old parent and position +- `move` - Node movement with old and new positions + +**Example:** +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +// Example +tree.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "tree") { + event.diff.diff.forEach(item => { + switch (item.action) { + case "create": + console.log(`Created node ${item.target}`); + break; + case "move": + console.log(`Moved node ${item.target}`); + break; + case "delete": + console.log(`Deleted node ${item.target}`); + break; + } + }); + } + } +}); +```` + + + +### Deep Subscription + +Subscribe to nested changes: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Subscribe to specific container +const text = doc.getText("text"); +text.subscribe((event) => { + console.log("Text changed:", event); +}); + +// Subscribe with deep observation +doc.subscribe((event) => { + // Path shows the location of the change + event.events.forEach((e) => { + console.log("Change path:", e.path); + console.log("Container:", e.target); + console.log("Diff:", e.diff); + }); +}); +``` + +--- + +## Undo/Redo + +Local undo operates on your own changes without breaking collaboration. See [Undo/Redo](/docs/advanced/undo) for design details and caveats. + +### UndoManager + +Provides local undo/redo functionality. + +**⚠️ Important Notes:** + +- **Local-only**: UndoManager only undoes the local user's operations, not remote operations +- **Origin filtering**: Use `excludeOriginPrefixes` to exclude certain operations (e.g., sync operations) from undo +- **Cursor restoration**: Use `onPush`/`onPop` callbacks to save and restore cursor positions + + +```typescript no_run +constructor(doc: LoroDoc, config: UndoConfig) +``` + + +Creates a new UndoManager instance. + +**Parameters:** + +- `doc` - The LoroDoc to manage undo/redo for +- `config` - Configuration options + - `mergeInterval?` - Time in ms to merge consecutive operations (default: 1000) + - `maxUndoSteps?` - Maximum number of undo steps to keep (default: 100) + - `excludeOriginPrefixes?` - Array of origin prefixes to exclude from undo + - `onPush?` - Callback when adding to undo stack + - `onPop?` - Callback when undoing/redoing + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const undo = new UndoManager(doc, { + mergeInterval: 1000, + maxUndoSteps: 100, + excludeOriginPrefixes: ["sync-"], +}); +``` + + + + +```typescript no_run +undo(): boolean +``` + + +Undo the last operation. + +**Returns:** True if undo was successful + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); + +// Usage example: +const success = undo.undo(); +console.log(success); // true +``` + + + + +```typescript no_run +redo(): boolean +``` + + +Redo the last undone operation. + +**Returns:** True if redo was successful + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); +undo.undo(); + +// Usage example: +const success = undo.redo(); +console.log(success); // true +``` + + + + +```typescript no_run +canUndo(): boolean +``` + + +Check if undo is available. + +**Returns:** True if can undo + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +if (undo.canUndo()) { + undo.undo(); +} +``` + + + + +```typescript no_run +canRedo(): boolean +``` + + +Check if redo is available. + +**Returns:** True if can redo + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +if (undo.canRedo()) { + undo.redo(); +} +``` + + + + +```typescript no_run +peer(): PeerID +``` + + +Get the peer ID of the undo manager. + +**Returns:** The peer ID + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +const peerId = undo.peer(); +console.log(peerId); // e.g., "123456" +``` + + + + +```typescript no_run +setMaxUndoSteps(steps: number): void +``` + + +Set the maximum number of undo steps. + +**Parameters:** + +- `steps` - Maximum number of undo steps + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.setMaxUndoSteps(50); +``` + + + + +```typescript no_run +setMergeInterval(interval: number): void +``` + + +Set the merge interval for grouping operations. + +**Parameters:** + +- `interval` - Merge interval in milliseconds + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.setMergeInterval(2000); // 2 seconds +``` + + + + +```typescript no_run +groupStart(): void +``` + + +Begin a manual grouping of subsequent commits into a single undo step. + +**Behavior:** + +- Wrap consecutive `doc.commit()` calls so they undo together +- Calling `groupStart` again before `groupEnd` throws and leaves the current group unchanged +- Conflicting remote imports may automatically end the group and split the undo item + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); + +undo.groupStart(); + +text.update("hello", undefined); +doc.commit(); + +text.update("hello world", undefined); +doc.commit(); + +undo.groupEnd(); + +undo.undo(); + +console.log(text.toString()); // "" +``` + + + + +```typescript no_run +groupEnd(): void +``` + + +Close the active manual group and enqueue the grouped operations as a single undo item. + +**Behavior:** + +- Must be paired with a prior `groupStart` +- Safe to call after the group was auto-closed by a conflicting remote import (becomes a no-op) +- Non-conflicting remote updates from other peers remain outside the undo item but do not break the group + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); + +undo.groupStart(); + +text.update("hello", undefined); +doc.commit(); +text.update("hello world", undefined); +doc.commit(); + +const snapshot = doc.export({ mode: "snapshot" }); +const doc2 = new LoroDoc(); +doc2.import(snapshot); +doc2.getText("text2").update("hello world world", undefined); // touches a different container +doc2.commit(); +const update = doc2.export({ mode: "update" }); + +doc.import(update); // remote, non-conflicting change + +text.update("hello world world world", undefined); +doc.commit(); + +undo.groupEnd(); + +undo.undo(); + +console.log(text.toString()); // "" +``` + + + + +```typescript no_run +addExcludeOriginPrefix(prefix: string): void +``` + + +Add a prefix to exclude from undo stack. + +**Parameters:** + +- `prefix` - Origin prefix to exclude + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.addExcludeOriginPrefix("sync-"); +undo.addExcludeOriginPrefix("import-"); +``` + + + + +```typescript no_run +clear(): void +``` + + +Clear the undo and redo stacks. + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.clear(); +``` + + + +### Custom Undo Handlers + +Handle cursor restoration and side effects: + +```typescript no_run threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +declare function saveCursorPositions(): any; +declare function restoreCursorPositions(cursors: any): void; + +// Usage example: +const undo = new UndoManager(doc, { + onPush: (isUndo, counterRange, event) => { + // Save cursor positions when adding to undo stack + const cursors = saveCursorPositions(); + return { + value: doc.toJSON(), + cursors: cursors, + }; + }, + + onPop: (isUndo, { value, cursors }, counterRange) => { + // Restore cursor positions when undoing + restoreCursorPositions(cursors); + }, +}); +``` + +--- + +## Types & Interfaces + +Reference for core types used across the API. For conceptual background, see [Containers](/docs/concepts/container), [Version Vector](/docs/concepts/version_vector), [Frontiers](/docs/concepts/frontiers), and the [Versioning Deep Dive](/docs/advanced/version_deep_dive). + +### Core Types + +```typescript no_run +// Peer identifier +type PeerID = `${number}`; + +// Container identifier +type ContainerID = + | `cid:root-${string}:${ContainerType}` + | `cid:${number}@${PeerID}:${ContainerType}`; + +// Tree node identifier +type TreeID = `${number}@${PeerID}`; + +// Operation identifier +type OpId = { + peer: PeerID; + counter: number; +}; + +// Container types +type ContainerType = + | "Text" + | "Map" + | "List" + | "Tree" + | "MovableList" + | "Counter"; + +// Value types +type Value = + | ContainerID + | string + | number + | boolean + | null + | { [key: string]: Value } + | Uint8Array + | Value[] + | undefined; +``` + +### Version Types + +Loro uses two complementary version representations: Version Vectors (per-peer counters) and Frontiers (a compact set of heads). See [Version Vector](/docs/concepts/version_vector) and [Frontiers](/docs/concepts/frontiers). For the full DAG model, see [Version Deep Dive](/docs/advanced/version_deep_dive). + +```typescript no_run +// Version vector class +class VersionVector { + constructor( + value: Map | Uint8Array | VersionVector | undefined | null, + ); + static parseJSON(version: Map): VersionVector; + toJSON(): Map; + encode(): Uint8Array; + static decode(bytes: Uint8Array): VersionVector; + get(peer_id: number | bigint | `${number}`): number | undefined; + compare(other: VersionVector): number | undefined; + setEnd(id: { peer: PeerID; counter: number }): void; + setLast(id: { peer: PeerID; counter: number }): void; + remove(peer: PeerID): void; + length(): number; +} + +// Frontiers represent a specific version +type Frontiers = OpId[]; + +// ID span for range queries +type IdSpan = { + peer: PeerID; + counter: number; + length: number; +}; +``` + +### Change Types + +```typescript no_run +// Change metadata +interface Change { + peer: PeerID; + counter: number; + lamport: number; + length: number; + timestamp: number; // Unix timestamp in seconds + deps: OpId[]; + message: string | undefined; +} + +// Change modifier for pre-commit hooks +interface ChangeModifier { + setMessage(message: string): this; + setTimestamp(timestamp: number): this; +} +``` + +### Cursor Types + +Stable cursors survive concurrent edits and resolve to absolute positions on demand. See [Cursor and Stable Positions](/docs/concepts/cursor_stable_positions) and [Cursor tutorial](/docs/tutorial/cursor). + +```typescript no_run +// Stable position in containers +class Cursor { + containerId(): ContainerID; + pos(): OpId | undefined; + side(): Side; // -1 | 0 | 1 + encode(): Uint8Array; + static decode(data: Uint8Array): Cursor; +} + +// Cursor side affinity +type Side = -1 | 0 | 1; +``` + +### Delta Type + +Delta is a popular rich-text operation format (e.g., Quill). LoroText can export/import Delta; see [Text](/docs/tutorial/text). + +```typescript no_run +// Rich text delta operations +type Delta = + | { + insert: T; + attributes?: { [key in string]: {} }; + retain?: undefined; + delete?: undefined; + } + | { + delete: number; + attributes?: undefined; + retain?: undefined; + insert?: undefined; + } + | { + retain: number; + attributes?: { [key in string]: {} }; + delete?: undefined; + insert?: undefined; + }; +``` + +--- + +## Utility Functions + +Small helpers for type checks and IDs. See [Container IDs](/docs/advanced/cid) and [Versioning Deep Dive](/docs/advanced/version_deep_dive) for frontiers/version encoding. + +### Frontier Encoding + + +```typescript no_run +encodeFrontiers(frontiers: OpId[]): Uint8Array +``` + + +Encode frontiers for efficient transmission. + +**Parameters:** + +- `frontiers` - Array of operation IDs representing frontiers + +**Returns:** Encoded bytes + +**Example:** + +```ts threeslash +import { LoroDoc, encodeFrontiers } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const encoded = encodeFrontiers(frontiers); +// Send encoded to remote peers +``` + + + + +```typescript no_run +decodeFrontiers(bytes: Uint8Array): OpId[] +``` + + +Decode frontiers from bytes. + +**Parameters:** + +- `bytes` - Encoded frontier bytes + +**Returns:** Array of operation IDs + +**Example:** + +```typescript no_run threeslash +import { decodeFrontiers } from "loro-crdt"; + +declare const encodedData: Uint8Array; +const frontiers = decodeFrontiers(encodedData); +console.log(frontiers); // [{ peer: "1", counter: 10 }, ...] +``` + + + +### Debugging + + +```typescript no_run +setDebug(): void +``` + + +Enable debug mode for detailed logging. + +**Example:** + +```ts threeslash +import { setDebug } from "loro-crdt"; + +// Enable debug logging +setDebug(); +``` + + + + +```typescript no_run +LORO_VERSION(): string +``` + + +Get the current Loro version. + +**Returns:** Version string + +**Example:** + +```ts threeslash +import { LORO_VERSION } from "loro-crdt"; + +const version = LORO_VERSION(); +console.log("Loro version:", version); +``` + + + +### Import Blob Metadata + + +```typescript no_run +decodeImportBlobMeta(blob: Uint8Array, check_checksum: boolean): ImportBlobMetadata +``` + + +Decode metadata from an import blob. + +**Parameters:** + +- `blob` - The import blob bytes +- `check_checksum` - Whether to verify checksum + +**Returns:** Import blob metadata + +**Example:** + +```typescript no_run threeslash +import { decodeImportBlobMeta } from "loro-crdt"; + +declare const blob: Uint8Array; +const metadata = decodeImportBlobMeta(blob, true); +console.log("Blob metadata:", metadata); +``` + + + +--- + +## EphemeralStore + +Manages ephemeral state like cursor positions and user presence. See [Ephemeral Store](/docs/tutorial/ephemeral) for concepts and usage patterns. Each entry uses timestamp-based LWW (Last-Write-Wins) for conflict resolution. + +**⚠️ Important:** + +- EphemeralStore is a separate CRDT without history - history/operations are NOT persisted +- Perfect for temporary state: cursor positions, selections, typing indicators +- Each peer's state auto-expires after the timeout period +- Uses Last-Write-Wins + +### EphemeralStore + + +```typescript no_run +constructor(timeout?: number) +``` + + +Creates a new EphemeralStore instance. + +**Parameters:** + +- `timeout` - Duration in milliseconds. A peer's state is considered outdated if its last update is older than this timeout. Default is 30000ms (30 seconds). + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; + +// Create ephemeral store with 30 second timeout +const store = new EphemeralStore(30000); +``` + + + + +```typescript no_run +set(key: K, value: T[K]): void +``` + + +Set an ephemeral value. + +**Parameters:** + +- `key` - The key to set +- `value` - The value to store + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +store.set("cursor", { line: 10, column: 5 }); +store.set("selection", { start: 0, end: 10 }); +store.set("user", { name: "Alice", color: "#ff0000" }); +``` + + + + +```typescript no_run +get(key: K): T[K] | undefined +``` + + +Get an ephemeral value. + +**Parameters:** + +- `key` - The key to get + +**Returns:** The stored value or undefined + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); + +// Usage example: +const cursor = store.get("cursor"); +console.log(cursor); // { line: 10 } +``` + + + + +```typescript no_run +delete(key: K): void +``` + + +Delete an ephemeral value. + +**Parameters:** + +- `key` - The key to delete + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); + +// Usage example: +store.delete("cursor"); +``` + + + + +```typescript no_run +getAllStates(): Partial +``` + + +Get all ephemeral states. + +**Returns:** All stored states + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); +store.set("user", { name: "Alice" }); + +// Usage example: +const allStates = store.getAllStates(); +console.log(allStates); +``` + + + + +```typescript no_run +encode(key: K): Uint8Array +``` + + +Encode a specific key's state for transmission. + +**Parameters:** + +- `key` - The key to encode + +**Returns:** Encoded bytes + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); + +// Usage example: +const encoded = store.encode("cursor"); +// Send encoded to remote peers +``` + + + + +```typescript no_run +encodeAll(): Uint8Array +``` + + +Encode all states for transmission. + +**Returns:** Encoded bytes + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); +store.set("user", { name: "Alice" }); + +// Usage example: +const encoded = store.encodeAll(); +// Send encoded to remote peers +``` + + + + +```typescript no_run +apply(bytes: Uint8Array): void +``` + + +Apply remote updates. + +**Parameters:** + +- `bytes` - Encoded updates from remote peer + +**Example:** + +```typescript no_run threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +declare const remoteData: Uint8Array; +store.apply(remoteData); +``` + + + + +```typescript no_run +keys(): string[] +``` + + +Get all keys in the store. + +**Returns:** Array of keys + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); +store.set("user", { name: "Alice" }); + +// Usage example: +const allKeys = store.keys(); +console.log(allKeys); // ["cursor", "user"] +``` + + + + +```typescript no_run +subscribe(listener: EphemeralListener): () => void +``` + + +Subscribe to all ephemeral state changes. + +**Parameters:** + +- `listener` - Callback function for state changes + +**Returns:** Unsubscribe function + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +const unsubscribe = store.subscribe((event) => { + console.log("Ephemeral state changed:", event); +}); + +// Later, unsubscribe +unsubscribe(); +``` + + + + +```typescript no_run +subscribeLocalUpdates(listener: EphemeralLocalListener): () => void +``` + + +Subscribe to local ephemeral updates for syncing to remote peers. + +**Parameters:** + +- `listener` - Callback function that receives encoded updates + +**Returns:** Unsubscribe function + +**Example:** + +```typescript no_run threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +declare const websocket: { send: (data: Uint8Array) => void }; + +const unsubscribe = store.subscribeLocalUpdates((data) => { + // Send to remote peers + websocket.send(data); +}); + +// Later, unsubscribe +unsubscribe(); +``` + + + + +```typescript no_run +destroy(): void +``` + + +Clean up and destroy the ephemeral store. + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +store.destroy(); +``` + + + +### Complete Example + +```typescript no_run threeslash +import { EphemeralStore } from "loro-crdt"; + +// Assume we have: +declare const websocket: { + send: (data: Uint8Array) => void; + on: (event: string, handler: (data: any) => void) => void; +}; + +const store = new EphemeralStore(30000); +const store2 = new EphemeralStore(30000); + +// Subscribe to local updates +store.subscribeLocalUpdates((data) => { + store2.apply(data); +}); + +// Subscribe to all updates +store2.subscribe((event) => { + console.log("event:", event); +}); + +// Set a value +store.set("key", "value"); + +// Encode the value +const encoded = store.encode("key"); + +// Apply the encoded value +store2.apply(encoded); +``` + +--- + +
+ + +# FILE: pages/docs/examples.mdx + +--- +keywords: "crdt, example, get started, excalidraw" +description: "The examples of basic usage in Loro" +--- + +# Examples + +You can find the examples of basic usage in [Loro examples in Deno](https://github.com/loro-dev/loro-examples-deno); + +### loro-prosemirror + +GitHub: [loro-dev/loro-prosemirror](https://github.com/loro-dev/loro-prosemirror) + +It provides seamless integration between Loro and ProseMirror, a powerful rich text editor framework. It includes: + +- Document state synchronization with rich text support +- Cursor awareness and synchronization +- Undo/Redo support in collaborative editing + +It can also be used with [Tiptap](https://tiptap.dev/), a popular rich text editor built on top of ProseMirror. This means you can easily add collaborative editing capabilities to your Tiptap-based applications. + +### loro-codemirror + +GitHub: [loro-dev/loro-codemirror](https://github.com/loro-dev/loro-codemirror) + +This package provides integration between Loro and CodeMirror 6, a versatile code editor. It supports: + +- Document state synchronization +- Cursor awareness synchronization +- Undo/Redo functionality + +### loro-inspector + +GitHub: [loro-dev/loro-inspector](https://github.com/loro-dev/loro-inspector) + +import { ReactPlayer } from "../../components/video"; + + + + + +# FILE: pages/docs/concepts/frontiers.mdx + +--- +keywords: "frontiers, version, compact representation, DAG, CRDT" +description: "Understanding Frontiers in Loro - a compact way to represent document versions" +--- + +# Frontiers + +Frontiers are a compact way to represent document versions in Loro by identifying the "frontier" operations - the most recent operations that aren't followed by any other operations. + +## What are Frontiers? + +Think of Frontiers as **bookmarks in your document's history**. Instead of listing every change made by every collaborator (like Version Vectors do), Frontiers only point to the boundary operations that implicitly include all their ancestors through causal relationships. + +## Basic Usage + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("content"); +text.insert(0, "Hello World"); + +// Get current frontiers (usually 1-2 elements) +const frontiers = doc.frontiers(); +console.log(frontiers); // [{ peer: "...", counter: 0 }] + +// Use frontiers for checkpoints +const checkpoint = doc.frontiers(); +text.insert(5, " Beautiful"); + +// Restore to checkpoint +doc.checkout(checkpoint); +console.log(text.toString()); // Back to "Hello World" +``` + +## When to Use Frontiers + +Frontiers are ideal for: + +1. **Creating checkpoints** - Mark specific points in document history +2. **Time travel** - Navigate to exact operation points efficiently +3. **Storage optimization** - Remain compact even with many collaborators (usually 1-2 elements vs N entries in Version Vector) +4. **Recording milestones** - Save important document states + +## Quick Comparison + +| Aspect | Frontiers | Version Vectors | +|--------|-----------|----------------| +| **Size** | 1-2 elements typically | Grows with peer count | +| **Use Case** | Checkpoints, time travel | Synchronization, diffing | +| **Storage** | Very compact | Larger with many peers | +| **Unknown ops** | Cannot determine included ops | Can determine all included ops | + +## Practical Example + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("content"); +const checkpoints = new Map(); + +// Save checkpoint with frontiers +text.insert(0, "Draft version"); +checkpoints.set("draft", doc.frontiers()); + +// Make changes +text.delete(0, 5); +text.insert(0, "Final"); +checkpoints.set("final", doc.frontiers()); + +// Restore to any checkpoint +doc.checkout(checkpoints.get("draft")); +console.log(text.toString()); // "Draft version" +``` + +## Important Limitation + +Frontiers have a key limitation when dealing with unknown operations: +- When you have a Frontier pointing to operations you don't know about, you cannot determine the complete set of operation IDs included in that version +- Version Vectors don't have this limitation - they explicitly list all peers and their operation counts, so you always know exactly which operations are included + +**Example**: +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +// If you receive a frontier [{ peer: "unknown-peer", counter: 42 }] +// Without having the operations from "unknown-peer": +// - Frontiers: Cannot tell which specific operations are included +// - Version Vector: Would show { "unknown-peer": 43 }, clearly indicating ops 0-42 + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +// Frontiers are compact but require operation history for full information +``` + +This limitation is why Frontiers are best for scenarios where you have access to the operation history (like checkpoints in a local document), while Version Vectors are preferred for synchronization between peers who may not share complete history. + +## Conversion with Version Vectors + +Loro allows seamless conversion between representations: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const vv = doc.frontiersToVV(frontiers); // Convert to Version Vector +const backToFrontiers = doc.vvToFrontiers(vv); // Convert back +``` + +## Learn More + +For detailed technical explanation of how Frontiers work with Loro's DAG structure and causal ordering, see [Version Deep Dive](/docs/advanced/version_deep_dive). + +# FILE: pages/docs/concepts/choose_crdt_type.mdx + +--- +keywords: "crdt, crdts, application, data model, concurrent, conflict" +description: "Loro supports many CRDT types. You need to choose the correct type to model the data based on the algorithm semantics." +--- + +# How to Choose the Right CRDT Types + +Choosing the right CRDT type means understanding their potential behavior in concurrent editing situations and judging whether such behavior is acceptable for your application. + +For text, you can choose to represent it directly as a Value on a Map (where the Value can be a string type), or you can choose to use a Text CRDT. For the former, each operation completely overwrites the previous one, so if A and B make concurrent modifications, only one of their edits will remain in the end. For the latter, the CRDT will retain all concurrent insertions by both people, and concurrent deletions are combined to complete the deletion. For most text box edits, you might prefer the latter. But for something like editing a link, you might want to use the former. + +For Lists, concurrently removing the same element and inserting a single element creates a new element, differentiating from the semantics of Set on a Map (we may consider providing a list set method in the future). For representing coordinates, it's better to use a Map rather than a List. If you represent coordinates as [x, y], and the A client updates the y coordinate by deleting the y element and reinserting a new y_a, and the B client also deletes y and inserts y_b, then after merging, the array will become [x, y_a, y_b], which does not conform to the user's schema. Using a Map can prevent this problem. + +For nested data, also decide how the child container should get its identity: + +- Use a Map value, such as a string or plain object, when the field should have + Last-Write-Wins replacement semantics. +- Use a regular child container with `setContainer` or `insertContainer` when + each creation should produce a distinct child object. +- Use a mergeable child container with `ensureMergeableText`, + `ensureMergeableMap`, `ensureMergeableList`, and the other + `ensureMergeable*` methods when multiple peers may lazily initialize the same + child under the same Map key and should end up editing one shared child. + +This distinction matters for dynamic keys, schema migrations, date-keyed lists, +and per-entity subdocuments. In those cases, the child identity usually should +come from the logical position `(parent Map, key, type)` rather than from the +operation that happened to create it first. + + +# FILE: pages/docs/concepts/peerid_management.mdx + +# PeerID Management + +## Quick Reference + +**Peer IDs** are unique identifiers for each editing session in Loro's distributed system. They ensure operation uniqueness without coordination between peers. + +## Key Concepts + +- **Peer ID**: A 64-bit unique identifier for each client/session +- **Operation ID**: Combination of `(peerId, counter)` that uniquely identifies each operation +- **Counter**: Monotonically increasing number starting at 0 for each peer + +```ts +interface OpId { + peerId: `${number}`; // Unique peer identifier. It's string because 64bit integers are not supported in JS. + counter: number; // Monotonically increasing counter +} +``` + +## Peer ID Assignment + +### Automatic (Default) + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); // Gets a random peer ID +// Safe, no coordination needed +``` + +**Note**: New peer ID generated for each `LoroDoc` instance, even when loading same document. + +### Manual + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("123123123"); // You can only set 64 bit integers as peer IDs +``` + +⚠️ **Warning**: Manual assignment requires careful conflict avoidance. + +If you intentionally reuse a Peer ID, persist the document's local data (snapshot, updates, or durable cache) alongside that ID and load it before fetching or applying any remote updates. This ensures the `(peerId, counter)` pairs continue to reference the same operations. Skipping this step risks generating a new operation that reuses an existing operation ID, which leads to inconsistent replicas. + +## Counter System + +Each peer maintains a monotonic counter starting at 0: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); +const text = doc.getText("text"); + +text.insert(0, "H"); // Operation ("1", 0) +text.insert(1, "i!"); // Operation ("1", 1) and Operation ("1", 2) are created +console.log(doc.version()); // { "1": 2 } +``` + +**Properties**: Monotonic, continuous, per-peer, persistent. + +## Common Pitfalls + +❌ **Never**: +- Use user IDs as peer IDs, because an user can have multiple devices +- Use fixed IDs +- Reuse IDs without proper management +- Allow multiple browser tabs or processes to operate on the same reused Peer ID in parallel—coordinate access with locks so that only one session emits operations for that ID at a time + +## Related Documentation + +- [Version Vector](./version_vector) - How peer IDs form version vectors +- [Import Status](./import_status) - Handling synchronization with peer IDs +- [Shallow Snapshots](./shallow_snapshots) - Consolidating peer history + + +# FILE: pages/docs/concepts/crdt.mdx + +import Image from "next/image"; + +# What are CRDTs + +CRDT (conflict-free replicated data type) is a data structure that can be +replicated across multiple computers in a network, where replicas can be updated +independently and in parallel, without the need for coordination between +replicas, and with a guarantee that no conflicts will occur. + +CRDT is often used in collaborative software, such as scenarios where multiple +users need to work together to edit/read a shared document, database, or state. +It can be used in database software, text editing software, chat software, etc. + +# What problems does CRDT solve? + +For example, a scenario where multiple users edit the same document online at +the same time. + +This scenario requires that each user sees the same content, even after +concurrent edits by different users (e.g. two users changing the title at the +same time), which is known as **consistency**. (To be precise, CRDT satisfies +the eventual consistency, see below for more details) + +Users can use CRDT even when they are offline. They can be back on sync with +others the network is restored. It also supports collaboratively editing with +other users via P2P. It is known as **partitioning fault tolerance**. This +allows CRDT to support **decentralized** applications very well: synchronization +can be done even without a centralized server. + +# The Emergence of CRDTs + +The formal concept of Conflict-free Replicated Data Types (CRDTs) was first +introduced in Marc Shapiro's 2011 paper, +[Conflict-Free Replicated Data Types](https://inria.hal.science/hal-00932836/file/CRDTs_SSS-2011.pdf). +However, it can be argued that the groundwork for CRDTs was laid earlier, in the +2006 study [Woot](https://doi.org/10.1145%2F1180875.1180916): An Algorithm for +Collaborative Real-time Editing. The primary motivation behind developing CRDTs +was to address the challenges associated with designing conflict resolution +mechanisms for eventual consistency. Prior to the introduction of CRDTs, +literature on the subject offered limited guidance, and ad hoc solutions were +often prone to errors. Consequently, Shapiro's paper presented a simple, +theoretically sound approach to achieving eventual consistency through the use +of CRDTs. + +(PS: Marc Shapiro actually wrote a paper +[Designing a commutative replicated data type](https://hal.inria.fr/inria-00177693v2/document) +in 2007. In 2011, he reworded commutative into conflict-free, expanding +the definition of commutative to include state-based CRDT) + +According to [CAP theorem](https://en.wikipedia.org/wiki/CAP_theorem), it is +impossible for a distributed computing system to perfectly satisfy the following +three points at the same time. + +- _Consistency_: each read receives the result of the most recent write or + reports an error; it behaves as if it is accessing the same piece of data +- _Availability_: every request gets a non-error response - but there is no + guarantee that the data fetched is up-to-date +- _Partition tolerance_: the ability of a distributed system to continue + functioning properly even when communication between its different components + is lost or delayed, resulting in a partition or network failure. + +If the system cannot achieve data consistency within the time limit, it means +that partitioning has occurred and a choice must be made between C and A for the +current operation, so "perfect consistency" is in conflict with "perfect +availability". + +CRDTs do not provide "perfect consistency", but +Strong Eventual Consistency (SEC). This +means that site A may not immediately reflect the state changes from site B, but +when A and B synchronize their messages they both regain consistency and do not +need to resolve potential conflicts (CRDT mathematically prevents conflicts from +occurring). _Strong Eventual Consistency_ does not conflict with _Availability_ +and _Partition Tolerance_. CRDTs provide a good CAP tradeoff. + +![CPA](./crdt-images/a4858e2a50bc1a2d79722060156e89b0cac5815cf25e8c67e409aa0926280cef.png) +_CRDT satisfies A + P + Eventual Consistency; a good tradeoff under CAP_ + +(PS: In 2012, Eric Brewer, author of the CAP theorem, wrote an article +[CAP Twelve Years Later: How the "Rules" Have Changed](https://www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed/), +explaining that the description of the "two out of three CAP features" is +actually misleading, and that the CAP actually prohibits perfect availability +and consistency in a very small part of the design space, i.e., in the presence +of partitions; in fact, the design of the tradeoff between C and A is very +flexible. A good example is CRDT.) + +# A simple CRDT case + +We can use a few simple examples to get a general idea of how CRDTs achieve +**Strong Eventual Consistency**. + +> **Grow-only Counter** + +How can we count the number of times something happens in a distributed system +without locking? + +G-Counter + +- Let each copy increments only its own counter => no locking synchronization & + no conflicts +- Each copy keeps the count values of all other copies at the same time +- Number of occurrences = sum of count values of all copies +- Since each copy only updates its own count and does not conflict with other + counters, this type satisfies consistency after message synchronization + +> **Grow-only Set** + +G-Set + +- The elements in a Grow-only Set can only be increased and not decreased +- To merge two such states, you only need to do a merge set +- This type satisfies consistency after message synchronization because there + are no conflicting operations since the elements only grow and do not + decrease. + +Both of these methods are CRDTs, and they both satisfy the following properties + +- They can both be updated independently and concurrently, without coordination + (locking) between replicas +- There is no possibility of conflict between multiple updates +- Final consistency can always be guaranteed + +# Introduction to the Principle + +There are two types of CRDTs: Op-based CRDTs and State-based CRDTs. This article +focuses on the concept of Op-based CRDTs. + +Op-based CRDTs operate on the principle that if two users perform identical +sequences of operations, the final state of the document should also be +identical. To achieve this, each user saves all the operations performed on the +data (Operations) and synchronizes these Operations with other users to ensure a +consistent final state. A critical challenge in this approach is ensuring the +order of Operations remains consistent, especially when parallel modification +operations occur. To address this, Op-based CRDTs require that all possible +parallel Operations be commutative, satisfying the final consistency +requirement. + +# Comparison of CRDT and OT + +Both CRDT and [Operation Transformation(OT)][ot] can be used in online +collaborative applications, with the following differences + +| OT | CRDT | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------ | +| OT relies on a centralized server for collaboration; [it is extremely difficult to make it work in a distributed environment](https://digitalfreepen.com/2018/01/04/operational-transform-hard.html) | CRDT algorithm can be used to synchronize data through a P2P approach synchronization | +| The earliest paper on OT was presented in 1989 | The earliest paper on CRDT appeared in 2006 | +| The OT algorithm is designed with higher complexity to ensure consistency | The CRDT algorithm is designed to be simpler to ensure consistency | +| It is easier to design OT to preserve user intent | It is more difficult to design a CRDT algorithm that preserves user intent | +| OT does not affect document size | CRDT documents are larger than the original document data | + +[a highly-available move operation for replicated trees]: + https://martin.kleppmann.com/papers/move-op.pdf +[moving elements in list crdts]: + https://martin.kleppmann.com/papers/list-move-papoc20.pdf +[interleaving anomalies in collaborative text editors]: + https://martin.kleppmann.com/papers/interleaving-papoc19.pdf +[conflict-free replicated data types]: https://readpaper.com/paper/1516319412 +[5000x faster crdts: an adventure in optimization]: + https://josephg.com/blog/crdts-go-brrr/ +[json crdt]: https://arxiv.org/abs/1608.03960 +[yata]: + https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types +[yjs]: https://github.com/yjs/yjs +[loro]: https://loro.dev +[automerge]: https://github.com/automerge/automerge +[half grid]: https://zh.wikipedia.org/wiki/%E5%8D%8A%E6%A0%BC +[ot]: https://en.wikipedia.org/wiki/Operational_transformation +[crdts and the quest for distributed consistency]: + https://www.infoq.com/presentations/crdt-distributed-consistency/ + + +# FILE: pages/docs/concepts/oplog_docstate.mdx + +--- +keywords: "crdt, oplog, snapshot, doc state, checkout, version, architecture, memory, performance" +description: "Understanding Loro's architectural separation of OpLog and DocState for efficient CRDT operations and version control" +--- + +# OpLog and DocState + +## Quick Reference + +- **OpLog** = Sequence of events/operations that compose the document history +- **DocState** = Current materialized state of the document + +This separation enables time travel, efficient sync, and flexible storage strategies. + +## Key Concepts + +**OpLog (Operation Log)** +- Append-only sequence of operations +- Causal relationships and metadata + +**DocState (Document State)** +- Current materialized view of the document +- Actual data structures and values +- What your app reads and displays + +## Benefits + +- **Memory efficiency**: Load OpLog without state (relay servers) +- **Time travel**: Navigate history without losing the log +- **Fast startup**: Load state via snapshots, fetch history later +- **Flexible storage**: Store separately for optimization + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); + +// Edit updates both +doc.getText("text").insert(0, "Hello"); +console.log(doc.oplogVersion()); // Latest known version +console.log(doc.version()); // The version of the current state of the document +// If the document is attached, they are the same. +``` + +## Time Travel & Detachment + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "v1"); +const v1 = doc.frontiers(); + +doc.getText("text").insert(2, " -> v2"); + +// Checkout old version - DocState diverges from OpLog +doc.checkout(v1); +console.log(doc.version()); // v1 state +console.log(doc.oplogVersion()); // Still has v2 +console.log(doc.isDetached()); // true + +// Return to latest +doc.attach(); +``` + +**Detached state**: DocState shows old version while OpLog has all operations. Editing disabled by default. + + +## Export Strategies + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +// Update: OpLog only (sync) +const update = doc.export({ mode: "update" }); + +// Snapshot: OpLog + DocState (persistence) +const snapshot = doc.export({ mode: "snapshot" }); + +// Shallow: Minimal OpLog + DocState (fast startup) +const shallow = doc.export({ mode: "shallow-snapshot", frontiers: doc.frontiers() }); +``` + +## Common Patterns + +### Relay Server (OpLog Only) +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +class RelayServer { + private doc: LoroDoc; + constructor() { + this.doc = new LoroDoc(); + this.doc.detach(); // Never materialize state + } + + handleUpdate(update: Uint8Array) { + this.doc.import(update); + } +} +``` + +## Best Practices + +- **Export modes**: Updates for sync, snapshots for persistence, shallow for startup +- **Optimize by use case**: + - Editors: Both in memory + - Relays: OpLog only + +## Related Documentation + +- [Attached/Detached States](./attached_detached) - Document and container states +- [Shallow Snapshots](./shallow_snapshots) - History trimming +- [Time Travel](../tutorial/time_travel) - Using checkout + + +# FILE: pages/docs/concepts/container.mdx + +# Container + +Containers are the fundamental building blocks in Loro for organizing and structuring collaborative data. They provide typed data structures that automatically merge when concurrent edits occur. + +## Container Types + +Loro provides several container types, each optimized for different use cases: + +- **LoroMap**: Key-value pairs with Last-Write-Wins semantics +- **LoroList**: Ordered sequences that merge concurrent insertions +- **LoroText**: Text with character-level merging and rich text support +- **LoroTree**: Hierarchical tree structures with move operations +- **LoroMovableList**: Lists with reordering capabilities +- **LoroCounter**: Numerical values with increment/decrement operations + +## Container States: Attached vs Detached + +Containers in Loro exist in two distinct states that affect their behavior and identity. + +### Detached Containers + +A container is **detached** when created directly using constructors: + +```ts twoslash +import { LoroMap, LoroText, LoroList } from "loro-crdt"; +// ---cut--- +// These containers are all detached +const map = new LoroMap(); +const text = new LoroText(); +const list = new LoroList(); +``` + +Characteristics of detached containers: +- Not yet part of any document +- Have a default placeholder ContainerID +- Can be used as templates or temporary data structures +- Will get a proper ContainerID when inserted into a document + +### Attached Containers + +A container becomes **attached** when it's part of a document hierarchy: + +```ts twoslash +import { LoroDoc, LoroMap, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); + +// Root containers are immediately attached +const rootMap = doc.getMap("myMap"); +const rootText = doc.getText("myText"); + +// Child containers: the returned value is attached +const detachedChild = new LoroText(); +const attachedChild = rootMap.setContainer("child", detachedChild); +// Note: detachedChild remains detached +// attachedChild is the attached version with proper ContainerID +``` + +Characteristics of attached containers: +- Belong to a specific document +- Have a proper ContainerID that uniquely identifies them +- Changes are tracked in the document's history +- Can be synchronized across peers + +## Container IDs + +Every attached container has a unique ContainerID that identifies it within the distributed system. The ID generation depends on the container type: + +- **Root containers**: ID derived from their name (e.g., "myMap" in `doc.getMap("myMap")`) +- **Child containers**: ID based on the operation that created them (OpID) + +This deterministic ID generation ensures that: +- The same container can be identified across all peers +- Container IDs are not random but contextually determined +- A detached container cannot have its final ID until insertion + +## Working with Containers + +### Creating Root Containers + +Root containers are created through the document API and are immediately attached: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); + +// These methods create or get root containers +const map = doc.getMap("settings"); +const text = doc.getText("content"); +const list = doc.getList("items"); +const tree = doc.getTree("hierarchy"); +``` + +### Nesting Containers + +Containers can be nested to create complex data structures: + +```ts twoslash +import { LoroDoc, LoroMap, LoroList, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const rootMap = doc.getMap("root"); + +// Method 1: Using setContainer (returns attached container) +const childText = rootMap.setContainer("description", new LoroText()); + +// Method 2: Using insertContainer for lists +const list = doc.getList("items"); +const childMap = list.insertContainer(0, new LoroMap()); +``` + +## Container Overwrites + +When initializing child containers in parallel, overwrites can occur instead of +automatic merging. For example: + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const a: string = "hello"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Parallel initialization of child containers +const docB = doc.fork(); +const textA = doc.getMap("map").setContainer("text", new LoroText()); +textA.insert(0, "A"); +const textB = docB.getMap("map").setContainer("text", new LoroText()); +textB.insert(0, "B"); + +doc.import(docB.export({ mode: "update" })); +// Result: Either { "meta": { "text": "A" } } or { "meta": { "text": "B" } } +``` + +This behavior poses a significant risk of data loss if the editing history is +not preserved. Even when the complete history is available and allows for data +recovery, the recovery process can be complex. + + + +When a container holds substantial data or serves as the primary storage for +document content, overwriting it can lead to the unintended hiding/loss of +critical information. For this reason, it is essential to implement careful and +systematic container initialization practices to prevent such issues. + +### Mergeable Containers + +The overwrite happens because `setContainer` creates a regular child container. +Its Container ID includes the operation that created it, so two peers that +create `map["text"]` concurrently create two different Text containers. + +When a child container should be identified by its logical position in a Map, +use a mergeable child container instead: + +```ts no_run +const text = doc.getMap("map").ensureMergeableText("text"); +text.insert(0, "A"); +``` + +Peers that call `ensureMergeableText("text")` on the same parent Map address the +same logical Text container. The same pattern is available for Map, List, +MovableList, Tree, and Counter children through the `ensureMergeable*` methods. + +### Best Practices + +1. For dynamic Map keys where peers may lazily create the same child, use + `ensureMergeableText`, `ensureMergeableMap`, `ensureMergeableList`, and the + other `ensureMergeable*` methods. + +2. When the structure is fixed and known ahead of time: + - If possible, initialize all child containers during the map container's + initialization + +3. Use regular `setContainer` / `insertContainer` when each creation should + produce a distinct child object, or when you are modeling replacement rather + than shared initialization. + +4. A unique root container name can still be a good fit when the child naturally + belongs at the root, for example `doc.getMap("user." + userId)`. + + +## Related Concepts + +- [Container ID](/docs/advanced/cid): Deep dive into how Container IDs work +- [Choosing CRDT Types](/docs/concepts/choose_crdt_type): Guide for selecting the right container type +- [Composition](/docs/tutorial/composition): How to compose containers into complex structures + + +# FILE: pages/docs/concepts/attached_detached.mdx + +--- +keywords: "attached, detached, state, container, document, version control" +description: "Understanding attached and detached states in Loro - two different but related concepts" +--- + +# Attached vs Detached States + +## Quick Reference + +Loro uses "attached/detached" in two distinct contexts: + +1. **Document States** - Version synchronization (latest vs historical) +2. **Container States** - Document membership (belongs to doc vs standalone) + +⚠️ These are independent concepts - a container can be attached to a detached document. + +## Document States + +### Attached (Default) +- Synchronized with latest OpLog version +- Normal editing mode +- All operations applied immediately + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +console.log(doc.isDetached()); // false - normal state +``` + +### Detached (Time Travel) +- Viewing historical version +- Editing disabled by default +- OpLog has newer operations not shown + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "v1"); +const v1 = doc.frontiers(); +doc.getText("text").insert(2, " -> v2"); + +// Time travel to v1 +doc.checkout(v1); +console.log(doc.isDetached()); // true +console.log(doc.getText("text").toString()); // "v1" +``` + + +## Container States + +### Detached (Standalone) +- Created with constructors (`new LoroMap()`) +- Not part of any document +- No valid ContainerID +- Used as templates + +```ts twoslash +import { LoroMap } from "loro-crdt"; +// ---cut--- +const map = new LoroMap(); +console.log(map.isAttached()); // false +``` + +### Attached (Document Member) +- Part of document hierarchy +- Has ContainerID +- Changes tracked in document + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const map = doc.getMap("data"); // Attached + +// Adding detached container returns attached version +const detached = new LoroText(); +const attached = map.setContainer("text", detached); +console.log(detached.isAttached()); // false - original unchanged +console.log(attached.isAttached()); // true - new attached copy +``` + +## Key Differences + +| Aspect | Document Attached/Detached | Container Attached/Detached | +|--------|---------------------------|-----------------------------| +| **Purpose** | Version control | Document membership | +| **When** | Time travel, branching | Adding to document | +| **Independence** | N/A | Independent from doc state | +| **Editing** | Restricted when detached | Always allowed | + +## Common Use Cases + +### Time Travel +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const v1 = doc.frontiers(); +// ... more edits ... +doc.checkout(v1); // View old version +doc.attach(); // Return to latest +``` + +### Container Templates +```ts twoslash +import { LoroDoc, LoroMap } from "loro-crdt"; +// ---cut--- +const template = new LoroMap(); +template.set("type", "task"); + +const doc = new LoroDoc(); +const list = doc.getList("tasks"); +// Reuse template multiple times +list.insertContainer(0, template); +list.insertContainer(1, template); +``` + +## Related Documentation + +- [OpLog and DocState](./oplog_docstate) - Understanding version states +- [Containers](./container) - Container types and usage +- [Time Travel](../tutorial/time_travel) - Using checkout and branching + + +# FILE: pages/docs/concepts/import_status.mdx + +# Import Status + +## Quick Reference + +**Import status** tells you what operations were applied and what's pending due to missing dependencies. Essential for handling out-of-order updates in distributed systems. + +## Status Structure + +```ts twoslash +interface ImportStatus { + success: PeerVersionRange; // What was applied + pending?: PeerVersionRange; // What needs dependencies +} + +interface PeerVersionRange { + [peerId: `${number}`]: { + start: number; // Inclusive + end: number; // Exclusive (e.g., 0-50 = ops 0-49) + }; +} +``` + + +## Pending Operations + +Operations become pending when they depend on missing operations (causal dependencies). + +### Common Scenario: Out-of-Order Delivery + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); + +// Peer A creates ops 0-4, then 5-10 +const peerA = new LoroDoc(); +peerA.getText("text").insert(0, "Hello"); +const update1 = peerA.export({ mode: "update" }); + +peerA.getText("text").insert(5, " World"); +const update2 = peerA.export({ mode: "update", from: peerA.version() }); + +// If update2 arrives first: +const status = doc.import(update2); +console.log(status.pending); // Ops 10-19 pending, need 0-9 + +// Import missing dependencies: +doc.import(update1); // Both updates now applied +``` + +## Handling Pending Operations + +```ts twoslash +import { LoroDoc, VersionVector } from "loro-crdt"; +// ---cut--- +async function handleImport( + doc: LoroDoc, + update: Uint8Array, + fetchMissing: (from: VersionVector) => Promise +) { + const status = doc.import(update); + + if (status.pending) { + const missing = await fetchMissing(doc.version()); + doc.import(missing); + } +} +``` + + +## Best Practices + +### Always Check Status + +```ts twoslash no_run +import { LoroDoc } from "loro-crdt"; +const update = new Uint8Array(); +// ---cut--- +const doc = new LoroDoc(); +const status = doc.import(update); +if (status.pending) { + console.warn("Operations pending:", status.pending); + // Fetch missing updates +} +``` + +### Use Batch Import +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const updates: Uint8Array[] = [ + //... +]; +const doc = new LoroDoc(); +const batchStatus = doc.importBatch(updates); +// Single status check for all updates +``` + +## Related Documentation + +- [Version Vector](./version_vector) - Understanding version vectors +- [Synchronization Guide](../tutorial/sync) - Complete sync patterns +- [PeerID Management](./peerid_management) - How peer IDs affect import + + +# FILE: pages/docs/concepts/version_vector.mdx + +--- +keywords: "version vector, logical clock, distributed systems, CRDT, synchronization" +description: "Understanding Version Vectors in Loro - complete peer state tracking for synchronization" +--- + +# Version Vector + +Version Vectors are a fundamental concept in distributed systems that track the complete state of all peers by recording how many operations each peer has contributed. + +## What is a Version Vector? + +A Version Vector is a map from peer IDs to operation counters, explicitly listing every peer and their operation count. It provides a complete picture of which operations are included in a version. + +**Example**: `{ "peer-A": 5, "peer-B": 3 }` means this version includes operations 0-4 from peer A and operations 0-2 from peer B. + +## Key Characteristics + +- **Complete information**: Explicitly lists all peers and their operation counts +- **Grows with peer count**: Size increases as more peers join +- **No dependency on history**: Can determine included operations without accessing the operation log +- **Enables version comparison**: Can easily check if one version includes another + +## Basic Usage + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setPeerId("1"); +const text = doc.getText("content"); +text.insert(0, "Hello"); + +// Get version vector +const vv = doc.version(); +console.log(vv.toJSON()); // Map { "1" => 1 } +``` + +## When to Use Version Vectors + +Version Vectors are ideal for: + +1. **Synchronization protocols** - Determine what updates to send between peers +2. **Network communication** - Self-contained version information +3. **Distributed systems** - Track state across multiple nodes + +## Comparison with Frontiers + +| Aspect | Version Vectors | Frontiers | +|--------|----------------|-----------| +| **Size** | O(number of peers) | O(1-2) typically | +| **Information** | Complete peer states | Boundary operations only | +| **Use Case** | Synchronization | Checkpoints | +| **History Required** | No | Yes, for full information | + +## Conversion with Frontiers + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.version(); +const frontiers = doc.vvToFrontiers(vv); // Convert to Frontiers +const backToVV = doc.frontiersToVV(frontiers); // Convert back +``` + +## Related Documentation + +- [Frontiers](./frontiers) - Compact version representation +- [Version Tutorial](../tutorial/version) - Working with versions +- [Version Deep Dive](../advanced/version_deep_dive) - Technical details + + +# FILE: pages/docs/concepts/event_graph_walker.mdx + +--- +keywords: "crdt, event graph walker, eg-walker, synchronization, collaboration, algorithm, innovation" +description: "Comprehensive guide to Event Graph Walker (Eg-Walker), a revolutionary CRDT algorithm that enables simpler metadata and better performance in Loro" +--- + +# Event Graph Walker (Eg-Walker) + +Event Graph Walker (Eg-Walker) is a revolutionary CRDT algorithm that fundamentally changes how collaborative editing systems handle concurrent operations. Instead of storing complex CRDT metadata, Eg-Walker enables the use of simple indices by efficiently replaying relevant history when needed. + +> **Important Note**: Loro is not a strict implementation of Event Graph Walker. Rather, Loro is heavily inspired by Eg-Walker's design philosophy and incorporates its key insights to achieve similar properties - particularly the ability to use simple indices instead of complex CRDT metadata, and the efficient replay mechanism for handling concurrent operations. + +## The Problem: Complex CRDT Metadata + +Traditional CRDTs require extensive metadata to maintain consistency across distributed systems: + +- **RGA algorithm**: Requires operation ID and Lamport timestamp of the left neighbor +- **Yjs/Fugue**: Needs operation IDs of both left and right neighbors +- **Storage overhead**: Each operation carries significant metadata for position tracking +- **Tombstone accumulation**: Deleted content must be retained indefinitely + +This metadata complexity leads to: +- Increased storage requirements +- Slower performance as documents grow +- Complex garbage collection challenges +- Higher network bandwidth usage + +## The Innovation: Simple Indices with Smart Replay + +Eg-Walker introduces a paradigm shift: **record simple indices, replay when needed**. + +import { ReactPlayer } from "../../../components/video"; + + + +### Core Concept + +Instead of storing complex position descriptors, Eg-Walker: +1. Records operations with **simple indices** at the time of execution +2. Maintains a directed acyclic graph (DAG) of the edit history +3. **Replays relevant portions** of history when merging concurrent changes +4. Reconstructs the exact CRDT state only when needed + +## How It Works + +### The Event Graph + +In collaborative editing, parallel edits naturally form a directed acyclic graph (DAG), similar to Git's history: + +``` + A --- B --- C (User 1) + / \ +Root Merge + \ / + D --- E --- F (User 2) +``` + +Each node represents an operation, and edges represent causal dependencies. + +### The Algorithm + +#### 1. **Local Operations: Direct and Fast** + +When a user makes a local edit: +- Record the operation with its **current index position** +- No complex metadata calculation needed +- Immediate application to the document + +Example: +```javascript no_run +// Traditional CRDT (e.g., RGA) +insert({ + char: 'a', + leftId: 'op-123', + timestamp: 1234567890, + siteId: 'user-1' +}) + +// With Eg-Walker +insert({ + char: 'a', + index: 5 // Simple index! +}) +``` + +#### 2. **Merging Remote Operations: Smart Replay** + +When receiving remote operations: + +1. **Find the Lowest Common Ancestor (LCA)** between local and remote versions +2. **Replay operations** from the LCA to both versions +3. **Construct temporary CRDT state** to calculate effects +4. **Apply the merged result** + +The key insight: You only replay the **divergent portion** of history, not the entire document. + +#### 3. **Index Transformation** + +Consider this scenario: +- You highlight text from index 10 to 20 +- Concurrently, someone inserts 5 characters at index 3 +- Your highlight should shift to indices 15 to 25 + +Eg-Walker handles this by: +1. Identifying concurrent operations through the event graph +2. Replaying operations in causal order +3. Transforming indices based on the replay sequence + +## Performance Benefits + +### 1. **Reduced Storage** + +| Aspect | Traditional CRDT | Eg-Walker | +|--------|-----------------|-----------| +| Tombstone storage | Permanent | Can be garbage collected | +| Document growth | Linear with all operations | Linear with active operations | + +### 2. **Faster Local Updates** + +- **No metadata computation**: Direct index-based operations +- **No tombstone traversal**: Clean document state +- **Predictable performance**: O(1) or O(logN) for most local operations + +### 3. **Efficient Synchronization** + +- **Minimal replay**: Only divergent history portions +- **Bounded computation**: Proportional to concurrent edits, not document size +- **Smart caching**: Reuse computed states when possible + +## Implementation in Loro + +While Loro is not a pure Eg-Walker implementation, it draws heavily from Eg-Walker's innovations to achieve similar benefits. Loro implements Fugue (a modern CRDT algorithm) with Eg-Walker-inspired optimizations: + +- **Fugue's correctness guarantees** for text editing +- **Eg-Walker-inspired efficiency** in storage and computation +- **Simple index-based operations** at the API level +- **Smart replay mechanisms** for merging concurrent changes + +This hybrid approach provides: +```javascript no_run +// Simple API with powerful internals +const doc = new Loro(); +const text = doc.getText("content"); + +// Just use indices - Eg-Walker handles the complexity +text.insert(5, "Hello"); +text.delete(2, 3); + +// Efficient merging happens automatically +doc.import(remoteUpdate); +``` + +## Garbage Collection Revolution + +One of Eg-Walker's most significant advantages is **safe garbage collection**: + +### Traditional CRDTs +- Must keep all tombstones forever +- Document size grows unbounded +- Complex protocols for coordinated cleanup + +### With Eg-Walker +- Operations synchronized across all endpoints can be **safely removed** +- No new operations will be concurrent with fully-synchronized ones +- Document size remains proportional to **active content** + +## Research Foundation + +Eg-Walker is based on rigorous academic research: + +> [Collaborative Text Editing with Eg-walker: Better, Faster, Smaller](https://arxiv.org/abs/2409.14252) +> By: Joseph Gentle, Martin Kleppmann + +The algorithm has been proven to: +- Maintain strong eventual consistency +- Preserve all CRDT correctness properties +- Significantly reduce computational and storage overhead + + +# FILE: pages/docs/concepts/operations_changes.mdx + +# Operations and Changes + +## Quick Reference + +**Operations** are atomic edits. **Changes** are logical groups of operations with metadata. Understanding these helps optimize sync and performance. + +## Key Concepts + +### Operations +- Atomic units of change (insert a single Unicode character, delete a single character, insert an new entry to a map, etc.) +- Automatically merged internally for efficiency +- Each has unique ID: `(peerId, counter)` + +### Changes +- Groups of consecutive operations +- Include metadata (timestamp, dependencies, peer ID) +- Created by `commit()` or auto-commit + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Hello"); // Operation +text.insert(5, " World"); // Operation +doc.commit(); // Groups into one Change +``` + +## Automatic Merging + +Consecutive operations from same peer merge into one Change: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "abc"); +doc.commit(); // Change #1 + +text.insert(3, "def"); +doc.commit(); // Merges with #1 (same peer, consecutive) +``` + +## When New Changes Are Created + +1. **Cross-peer dependencies**: After importing remote operations +2. **Time separation**: When timestamps enabled and > the merge interval (default 1000s) between commits +3. **Different commit messages**: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "v1"); +doc.commit(); // Change #1 + +// Import from another peer +const doc2 = new LoroDoc(); +doc2.getText("text").insert(0, "v1"); +const remote = doc2.export({ mode: "update" }); +doc.import(remote); + +// Next commit creates new Change (dependency on remote) +doc.getText("text").insert(0, "v2"); +doc.commit(); // Change #2 +``` + + +## Impact on Sync & Storage + +- **History**: Changes track logical units of work +- **Sync**: Dependencies ensure causal ordering +- **Storage**: Auto-merging reduces metadata overhead + +## Related Documentation + +- [Transaction Model](./transaction_model) - Grouping operations +- [Version Vector](./version_vector) - How Changes form versions +- [Synchronization](../tutorial/sync) - Using Changes for sync + + +# FILE: pages/docs/concepts/when_not_crdt.mdx + +--- +keywords: "crdt, limitations, constraints, business logic, invariants" +description: "When CRDTs are not the right tool: hard invariants, exclusivity, ordering, and validation" +--- + +# When Not to Use CRDTs + +CRDTs shine for collaborative editing and offline-friendly applications. +But they are not a universal replacement for coordination, transactions, +or authorization. Use this guide to recognize when CRDTs are a poor fit and what to use instead. + +## Quick Reference + +CRDTs merge; they do not reject. This clashes with requirements that demand global agreement at the time of the action: +- **Hard invariants**: Must never be violated (e.g., balance ≥ 0) +- **Exclusive ownership**: Only one winner per slot/resource + +## Why CRDTs fall short here + +- Merging is monotonic: remote edits cannot be “rolled back” on arrival +- No locks or transactions across replicas +- Check-then-set across peers needs coordination, not just convergence + +## Common problem scenarios + +### 1) Exclusive resource management + +Multiple users book the same room/time concurrently. + +```ts no_run +// With a CRDT-only model both intents exist after merge +// The app must pick a winner out-of-band +book("A101@2pm", { by: "A" }) +book("A101@2pm", { by: "B" }) +// After sync: both entries are present → invariant violated without coordination +``` + +Use instead: +- Transactional authority (central server or database) +- Distributed consensus (Raft/Paxos) for ownership +- Hybrid: CRDT for UI drafts, authoritative booking via server + +### 2) Financial transactions + +Two withdrawals race on a $100 account. + +```ts no_run +// Each client applies its own withdrawal locally +withdraw(60) // A +withdraw(60) // B +// After merge, naive addition overdrafts without an authority +``` + +Use instead: +- ACID transactions on an authoritative store +- Command validation with a single writer per account +- Event-sourced ledger with server-side checks + +### Other cases to avoid CRDT-as-the-only-source + +- Uniqueness constraints (usernames, slugs, one primary per group) +- Authorization decisions that must be enforced at write time +- Global invariants (referential integrity, cross-document totals) + +## When CRDTs work well + +CRDTs excel when merged results are meaningful and temporary inconsistency is acceptable: + +- ✅ Collaborative text, lists, maps, rich presence +- ✅ Comments, reactions, drafts, annotations +- ✅ Offline edits where convergence is enough + + + +# FILE: pages/docs/concepts/cursor_stable_positions.mdx + +--- +keywords: "cursor, stable position, collaborative editing, CRDT, concurrent edits, selection, annotation, caret" +description: "Understanding cursor and stable position systems in Loro for maintaining accurate positions across concurrent edits" +--- + +# Cursor and Stable Positions + +## Quick Reference + +**Cursors** maintain stable positions across concurrent edits by anchoring to operation IDs instead of indices. Essential for collaborative editing features like collaborative cursors and persistent annotations. + +## How It Works + +Cursors anchor to operation IDs and ContainerIDs, not indices: + +``` +Text: H e l l o W o r l d +Op IDs: 1 2 3 4 5 6 7 8 9 A B +Cursor: References ID 5 (after 'o') + +After concurrent insert at start: +Text: N e w H e l l o W o r l d +Op IDs: C D E F 1 2 3 4 5 6 7 8 9 A B +Cursor: Still references ID 5 - position automatically adjusted +``` + +## Side Parameter + +- **`Side.Before` (-1)**: Stay before the target +- **`Side.Middle` (0)**: On the target (default) +- **`Side.After` (1)**: Stay after the target + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "ABC"); + +const cursor = text.getCursor(1, -1); // Before 'B' +text.insert(1, "X"); // Insert at cursor +// Result: "AXBC", cursor still before 'B' +const pos = doc.getCursorPos(cursor!); +console.log(pos); // { offset: 2, side: Side.Before } +``` + +## Common Use Cases + +### Text Selections +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Selection range +const start = text.getCursor(0, -1); // Anchor +const end = text.getCursor(5, 1); // Head +``` + +### List Positions +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const list = doc.getList("items"); +list.insert(0, ["a", "b", "c"]); + +const cursor = list.getCursor(0, 1); // After "a" +list.insert(0, "new"); // Cursor adjusts automatically +``` + +## Related Documentation + +- [Text Container](../tutorial/text) - Text editing with cursors +- [Cursor Tutorial](../tutorial/cursor) - Building collaborative features +- [Events](../tutorial/event) - Cursor change events + + +# FILE: pages/docs/concepts/shallow_snapshots.mdx + +# Shallow Snapshots + +## Quick Reference + +**Shallow snapshots** are like Git's shallow clone - maintain current state while removing old history. Essential for privacy compliance, storage optimization, and performance. + + +## Basic Usage + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// ... extensive editing history ... + +// Regular snapshot - full history +const full = doc.export({ mode: "snapshot" }); + +// Shallow snapshot - trimmed history +const shallow = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), +}); + +// Typically 70-90% smaller +console.log(`Size reduction: ${100 - (shallow.length / full.length * 100)}%`); +``` + +## Content Redaction + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("content"); + +// Sensitive data added +text.insert(0, "SSN: 123-45-6789. "); +text.insert(18, "Public info."); + +// Redact sensitive part +text.delete(0, 18); + +// Create clean snapshot +const redacted = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), +}); +// Sensitive data permanently removed from history +``` + +## Synchronization Limitations + +⚠️ **Important**: Peers can only sync if they have versions after the shallow snapshot point. + + +## Common Patterns + +### Archive and Trim +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +async function archiveAndTrim(doc: LoroDoc) { + // 1. Archive full history + const full = doc.export({ mode: "snapshot" }); + await saveToArchive(full); + + // 2. Create shallow for active use + const shallow = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), + }); + + return shallow; +} + +async function saveToArchive(data: Uint8Array) { + // Save to cold storage +} +``` + +### Privacy-Aware Design +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +class PrivacyDoc { + constructor(private doc: LoroDoc) {} + + async redactSensitive() { + // Delete sensitive content + this.doc.getText("private").delete(0, this.doc.getText("private").length); + + // Create clean snapshot + return this.doc.export({ + mode: "shallow-snapshot", + frontiers: this.doc.frontiers(), + }); + } +} +``` + +## Best Practices + +- **Coordinate before trimming**: Ensure all peers synchronized +- **Archive before deletion**: Keep full history backup if needed + +## Related Documentation + +- [Encoding](../tutorial/encoding) - Different export options +- [Version Tutorial](../tutorial/version) - Managing document versions +- [Event Graph Walker](./event_graph_walker) - Algorithm powering shallow snapshots + + +# FILE: pages/docs/concepts/transaction_model.mdx + +# Transaction Model + +## Quick Reference + +**Loro transactions are NOT database ACID transactions.** They are operation bundling mechanisms for event emission. + +## Key Concepts + +- **Purpose**: Bundle related operations and control event emission +- **No rollback**: Failed operations don't undo previous ones +- **Event batching**: Single event for all operations in transaction +- **History grouping**: Operations stay together for undo/redo + +## Basic Usage + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); + +// Without transaction - multiple events +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); // Event emitted +text.insert(5, " World"); +doc.commit(); // Another event +``` + +## How Transactions Work + +### Pending Operations +Local operations are **pending** by default and won't emit events until committed: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// Operation is pending, no event emitted yet +text.insert(0, "Hello"); + +// Explicit commit triggers event emission +doc.commit(); +``` + +### Implicit Commits +Certain operations trigger implicit commits automatically: + +```ts twoslash no_run +import { LoroDoc } from "loro-crdt"; +const update = new Uint8Array(); +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Hello"); // Pending operation + +// These operations trigger implicit commit: +doc.import(update); // Implicit commit before import +// or +doc.checkout(doc.frontiers()); // Implicit commit before checkout +// or +doc.export({ mode: "update" }); // Implicit commit before export +``` + +### Transaction Guarantees + +- All operations share the same timestamp (if enabled) +- Operations grouped in a single Change +- One event emitted after transaction completes +- Operations committed together at transaction end +- Before committing, the local edits are not synced to other peers + +## Related Documentation + +- [Operations and Changes](./operations_changes) - How transactions create Changes +- [Event System](../tutorial/event) - Understanding event emission +- [Undo/Redo](../advanced/undo) - Using transactions for undo boundaries + + +# FILE: pages/docs/performance/index.md + +--- +keywords: "loro, yjs, automerge, benchmark, memory, crdt" +description: "CRDT benchmarks, comparing the performance of Loro and popular CRDTs" +--- + +# JS/WASM Benchmarks + +> The primary role of these benchmarks should be to serve as indicators of the absence of performance pitfalls rather than as measures of which project is superior. This is because different projects consistently make different trade-offs. It is inaccurate to claim that Project A is superior to Project B simply because A performs better in certain benchmarks, while Project B may excel in other areas by a significant margin. + +The benchmark can be reproduced using the [crdt-benchmarks](https://github.com/zxch3n/crdt-benchmarks) repo. + +- The benchmarks were performed on MacBook Pro M1 2020 with 16GB RAM +- loro-old is the version of loro on 2023-11-10, it's compiled from + [this commit](https://github.com/loro-dev/loro/tree/c1613ee680c6a4757e55fcda76e4f5f627daeb56). + Loro has undergone numerous changes since then, particularly in terms of + [encoding schema](https://github.com/loro-dev/loro/pull/219), shifting from a + performance-focused version to one that prioritizes compatibility. Because we + want Loro to have good backward and forward compatibility after reaching + version 1.0, we have adopted a more easily extensible encoding method. It is + slower than the encoding method used in the `loro-old` version, but it better + ensures our ability to iterate quickly after reaching version 1.0 without + introducing breaking changes. +- There is a more exchaustive benchmark at the bottom that only runs benchmarks + on Yjs. +- Automerge can perform the `B4` benchmark in about 1 second (see `time`) if all + changes are applied within a single `change` transaction. However, our + benchmarks test individual edits that generate individual update events as + this more closely simulates actual user behavior. +- Note that `parseTime` is significantly higher with `automerge` and `loro` when + the initial document is not empty (e.g. when syncing content from a remote + server). +- Loro and Automerge can store a complete DAG of editing history for each +keystroke, but Yjs requires additional storage for a Version Vector + Delete +Set for each version saved, which incurs significant extra overhead beyond the +document size reported. +
+Benchmark setup + +#### B1: No conflicts + +Simulate two clients. One client modifies a text object and sends update +messages to the other client. We measure the time to perform the task (`time`), +the amount of data exchanged (`avgUpdateSize`), the size of the encoded document +after the task is performed (`docSize`), the time to parse the encoded document +(`parseTime`), and the memory used to hold the decoded document (`memUsed`). + +#### B2: Two users producing conflicts + +Simulate two clients. Both start with a synced text object containing 100 +characters. Both clients modify the text object in a single transaction and then +send their changes to the other client. We measure the time to sync concurrent +changes into a single client (`time`), the size of the update messages +(`updateSize`), the size of the encoded document after the task is performed +(`docSize`), the time to parse the encoded document (`parseTime`), and the +memory used to hold the decoded document (`memUsed`). + +#### B3: Many conflicts + +Simulate `√N` concurrent actions. We measure the time to perform the task and +sync all clients (`time`), the size of the update messages (`updateSize`), the +size of the encoded document after the task is performed (`docSize`), the time +to parse the encoded document (`parseTime`), and the memory used to hold the +decoded document (`memUsed`). The logarithm of `N` was chosen because `√N` +concurrent actions may result in up to `√N^2 - 1` conflicts (apply action 1: 0 +conflict; apply action2: 1 conflict, apply action 2: 2 conflicts, ..). + +#### B4: Real-world editing dataset + +Replay a real-world editing dataset. This dataset contains the +character-by-character editing trace of a large-ish text document, the LaTeX +source of this paper: https://arxiv.org/abs/1608.03960 + +Source: https://github.com/automerge/automerge-perf/tree/master/edit-by-index + +- 182,315 single-character insertion operations +- 77,463 single-character deletion operations +- 259,778 operations totally +- 104,852 characters in the final document + +We simulate one client replaying all changes and storing each update. We measure +the time to replay the changes and the size of all update messages +(`updateSize`), the size of the encoded document after the task is performed +(`docSize`), the time to encode the document (`encodeTime`), the time to parse +the encoded document (`parseTime`), and the memory used to hold the decoded +document in memory (`memUsed`). + +##### [B4 x 100] Real-world editing dataset 100 times + +Replay the [B4] dataset one hundred times. The final document has a size of over +10 million characters. As comparison, the book "Game of Thrones: A Song of Ice +and Fire" is only 1.6 million characters long (including whitespace). + +- 18,231,500 single-character insertion operations +- 7,746,300 single-character deletion operations +- 25,977,800 operations totally +- 10,485,200 characters in the final document + +
+ +| N = 6000 | yjs | ywasm | loro | loro-old | automerge | automerge-wasm | +| :----------------------------------------------------------------------- | ---------------: | --------------: | ---------------: | ---------------: | ---------------: | --------------: | +| Version | 13.6.15 | 0.17.4 | 1.0.0-beta.2 (historical) | 0.15.2 | 2.1.10 | 0.9.0 | +| Bundle size | 84,017 bytes | 938,991 bytes | 2,919,363 bytes | 1,583,094 bytes | 1,696,176 bytes | 1,701,136 bytes | +| Bundle size (gzipped) | 25,105 bytes | 284,616 bytes | 894,460 bytes | 592,039 bytes | 591,049 bytes | 594,071 bytes | +| [B1.1] Append N characters (time) | 141 ms | 171 ms | 164 ms | 115 ms | 279 ms | 110 ms | +| [B1.1] Append N characters (avgUpdateSize) | 27 bytes | 27 bytes | 88 bytes | 58 bytes | 121 bytes | 121 bytes | +| [B1.1] Append N characters (encodeTime) | 1 ms | 0 ms | 3 ms | 0 ms | 5 ms | 6 ms | +| [B1.1] Append N characters (docSize) | 6,031 bytes | 6,031 bytes | 12,382 bytes | 6,219 bytes | 3,992 bytes | 3,992 bytes | +| [B1.1] Append N characters (memUsed) | 0 B | 0 B | 0 B | 0 B | 0 B | 0 B | +| [B1.1] Append N characters (parseTime) | 22 ms | 65 ms | 28 ms | 28 ms | 59 ms | 61 ms | +| [B1.2] Insert string of length N (time) | 1 ms | 1 ms | 0 ms | 0 ms | 18 ms | 14 ms | +| [B1.2] Insert string of length N (avgUpdateSize) | 6,031 bytes | 6,031 bytes | 6,089 bytes | 6,088 bytes | 6,201 bytes | 6,201 bytes | +| [B1.2] Insert string of length N (encodeTime) | 0 ms | 0 ms | 0 ms | 0 ms | 2 ms | 2 ms | +| [B1.2] Insert string of length N (docSize) | 6,031 bytes | 6,031 bytes | 12,313 bytes | 6,146 bytes | 3,974 bytes | 3,974 bytes | +| [B1.2] Insert string of length N (parseTime) | 27 ms | 47 ms | 27 ms | 26 ms | 29 ms | 30 ms | +| [B1.3] Prepend N characters (time) | 118 ms | 30 ms | 111 ms | 47 ms | 272 ms | 73 ms | +| [B1.3] Prepend N characters (avgUpdateSize) | 27 bytes | 26 bytes | 87 bytes | 57 bytes | 116 bytes | 116 bytes | +| [B1.3] Prepend N characters (encodeTime) | 2 ms | 0 ms | 2 ms | 1 ms | 4 ms | 4 ms | +| [B1.3] Prepend N characters (docSize) | 6,041 bytes | 6,040 bytes | 15,414 bytes | 6,165 bytes | 3,988 bytes | 3,988 bytes | +| [B1.3] Prepend N characters (parseTime) | 45 ms | 38 ms | 27 ms | 37 ms | 73 ms | 47 ms | +| [B1.4] Insert N characters at random positions (time) | 128 ms | 101 ms | 113 ms | 51 ms | 268 ms | 89 ms | +| [B1.4] Insert N characters at random positions (avgUpdateSize) | 29 bytes | 29 bytes | 88 bytes | 58 bytes | 121 bytes | 121 bytes | +| [B1.4] Insert N characters at random positions (encodeTime) | 3 ms | 0 ms | 2 ms | 1 ms | 6 ms | 5 ms | +| [B1.4] Insert N characters at random positions (docSize) | 29,571 bytes | 29,554 bytes | 39,040 bytes | 29,503 bytes | 24,743 bytes | 24,743 bytes | +| [B1.4] Insert N characters at random positions (parseTime) | 46 ms | 30 ms | 27 ms | 26 ms | 73 ms | 64 ms | +| [B1.5] Insert N words at random positions (time) | 149 ms | 264 ms | 112 ms | 54 ms | 539 ms | 291 ms | +| [B1.5] Insert N words at random positions (avgUpdateSize) | 36 bytes | 36 bytes | 95 bytes | 65 bytes | 131 bytes | 131 bytes | +| [B1.5] Insert N words at random positions (encodeTime) | 6 ms | 1 ms | 3 ms | 1 ms | 14 ms | 15 ms | +| [B1.5] Insert N words at random positions (docSize) | 87,868 bytes | 87,924 bytes | 135,713 bytes | 98,901 bytes | 96,203 bytes | 96,203 bytes | +| [B1.5] Insert N words at random positions (parseTime) | 50 ms | 33 ms | 27 ms | 33 ms | 101 ms | 111 ms | +| [B1.6] Insert string, then delete it (time) | 1 ms | 0 ms | 2 ms | 1 ms | 39 ms | 31 ms | +| [B1.6] Insert string, then delete it (avgUpdateSize) | 6,053 bytes | 6,052 bytes | 6,189 bytes | 6,179 bytes | 6,338 bytes | 6,338 bytes | +| [B1.6] Insert string, then delete it (encodeTime) | 0 ms | 0 ms | 0 ms | 0 ms | 3 ms | 2 ms | +| [B1.6] Insert string, then delete it (docSize) | 6,040 bytes | 6,039 bytes | 6,409 bytes | 6,145 bytes | 3,993 bytes | 3,993 bytes | +| [B1.6] Insert string, then delete it (parseTime) | 29 ms | 29 ms | 26 ms | 28 ms | 52 ms | 44 ms | +| [B1.7] Insert/Delete strings at random positions (time) | 153 ms | 112 ms | 136 ms | 60 ms | 423 ms | 212 ms | +| [B1.7] Insert/Delete strings at random positions (avgUpdateSize) | 31 bytes | 31 bytes | 100 bytes | 61 bytes | 135 bytes | 135 bytes | +| [B1.7] Insert/Delete strings at random positions (encodeTime) | 3 ms | 1 ms | 3 ms | 1 ms | 13 ms | 11 ms | +| [B1.7] Insert/Delete strings at random positions (docSize) | 41,917 bytes | 41,592 bytes | 81,700 bytes | 51,470 bytes | 59,281 bytes | 59,281 bytes | +| [B1.7] Insert/Delete strings at random positions (parseTime) | 53 ms | 41 ms | 25 ms | 27 ms | 80 ms | 78 ms | +| [B1.8] Append N numbers (time) | 140 ms | 59 ms | 155 ms | 73 ms | 448 ms | 113 ms | +| [B1.8] Append N numbers (avgUpdateSize) | 32 bytes | 32 bytes | 94 bytes | 62 bytes | 125 bytes | 125 bytes | +| [B1.8] Append N numbers (encodeTime) | 2 ms | 0 ms | 2 ms | 2 ms | 6 ms | 6 ms | +| [B1.8] Append N numbers (docSize) | 35,641 bytes | 35,634 bytes | 71,568 bytes | 47,625 bytes | 26,985 bytes | 26,985 bytes | +| [B1.8] Append N numbers (parseTime) | 32 ms | 43 ms | 31 ms | 29 ms | 76 ms | 67 ms | +| [B1.9] Insert Array of N numbers (time) | 3 ms | 3 ms | 13 ms | 9 ms | 47 ms | 22 ms | +| [B1.9] Insert Array of N numbers (avgUpdateSize) | 35,653 bytes | 35,657 bytes | 35,715 bytes | 35,717 bytes | 31,199 bytes | 31,199 bytes | +| [B1.9] Insert Array of N numbers (encodeTime) | 1 ms | 0 ms | 2 ms | 1 ms | 4 ms | 2 ms | +| [B1.9] Insert Array of N numbers (docSize) | 35,653 bytes | 35,657 bytes | 71,578 bytes | 47,646 bytes | 26,953 bytes | 26,953 bytes | +| [B1.9] Insert Array of N numbers (parseTime) | 43 ms | 29 ms | 31 ms | 29 ms | 56 ms | 44 ms | +| [B1.10] Prepend N numbers (time) | 157 ms | 31 ms | 125 ms | 53 ms | 484 ms | 159 ms | +| [B1.10] Prepend N numbers (avgUpdateSize) | 32 bytes | 32 bytes | 93 bytes | 61 bytes | 120 bytes | 120 bytes | +| [B1.10] Prepend N numbers (encodeTime) | 3 ms | 1 ms | 3 ms | 1 ms | 6 ms | 6 ms | +| [B1.10] Prepend N numbers (docSize) | 35,669 bytes | 35,665 bytes | 76,773 bytes | 47,645 bytes | 26,987 bytes | 26,987 bytes | +| [B1.10] Prepend N numbers (parseTime) | 53 ms | 33 ms | 48 ms | 39 ms | 65 ms | 67 ms | +| [B1.11] Insert N numbers at random positions (time) | 160 ms | 121 ms | 125 ms | 56 ms | 517 ms | 121 ms | +| [B1.11] Insert N numbers at random positions (avgUpdateSize) | 34 bytes | 34 bytes | 94 bytes | 60 bytes | 125 bytes | 125 bytes | +| [B1.11] Insert N numbers at random positions (encodeTime) | 2 ms | 1 ms | 4 ms | 1 ms | 8 ms | 6 ms | +| [B1.11] Insert N numbers at random positions (docSize) | 59,132 bytes | 59,137 bytes | 100,632 bytes | 70,901 bytes | 47,746 bytes | 47,746 bytes | +| [B1.11] Insert N numbers at random positions (parseTime) | 52 ms | 50 ms | 58 ms | 41 ms | 428 ms | 80 ms | +| [B2.1] Concurrently insert string of length N at index 0 (time) | 1 ms | 0 ms | 1 ms | 0 ms | 75 ms | 32 ms | +| [B2.1] Concurrently insert string of length N at index 0 (updateSize) | 6,094 bytes | 6,094 bytes | 6,188 bytes | 9,244 bytes | 9,499 bytes | 9,499 bytes | +| [B2.1] Concurrently insert string of length N at index 0 (encodeTime) | 0 ms | 0 ms | 0 ms | 0 ms | 7 ms | 5 ms | +| [B2.1] Concurrently insert string of length N at index 0 (docSize) | 12,151 bytes | 12,151 bytes | 24,735 bytes | 12,281 bytes | 8,011 bytes | 8,011 bytes | +| [B2.1] Concurrently insert string of length N at index 0 (parseTime) | 28 ms | 34 ms | 30 ms | 27 ms | 60 ms | 48 ms | +| [B2.2] Concurrently insert N characters at random positions (time) | 46 ms | 166 ms | 55 ms | 125 ms | 270 ms | 399 ms | +| [B2.2] Concurrently insert N characters at random positions (updateSize) | 33,420 bytes | 33,444 bytes | 23,779 bytes | 350,337 bytes | 27,476 bytes | 1,093,293 bytes | +| [B2.2] Concurrently insert N characters at random positions (encodeTime) | 2 ms | 1 ms | 4 ms | 1 ms | 6 ms | 11 ms | +| [B2.2] Concurrently insert N characters at random positions (docSize) | 66,808 bytes | 66,852 bytes | 79,937 bytes | 59,358 bytes | 50,686 bytes | 50,704 bytes | +| [B2.2] Concurrently insert N characters at random positions (parseTime) | 67 ms | 69 ms | 27 ms | 27 ms | 51 ms | 95 ms | +| [B2.3] Concurrently insert N words at random positions (time) | 105 ms | 459 ms | 68 ms | 120 ms | 435 ms | 630 ms | +| [B2.3] Concurrently insert N words at random positions (updateSize) | 89,143 bytes | 88,994 bytes | 62,640 bytes | 408,723 bytes | 122,485 bytes | 1,185,202 bytes | +| [B2.3] Concurrently insert N words at random positions (encodeTime) | 7 ms | 2 ms | 7 ms | 3 ms | 28 ms | 35 ms | +| [B2.3] Concurrently insert N words at random positions (docSize) | 178,428 bytes | 178,130 bytes | 268,884 bytes | 197,284 bytes | 185,019 bytes | 191,498 bytes | +| [B2.3] Concurrently insert N words at random positions (parseTime) | 82 ms | 67 ms | 31 ms | 30 ms | 134 ms | 184 ms | +| [B2.4] Concurrently insert & delete (time) | 145 ms | 1,243 ms | 118 ms | 282 ms | 653 ms | 1,311 ms | +| [B2.4] Concurrently insert & delete (updateSize) | 140,984 bytes | 141,122 bytes | 123,725 bytes | 798,123 bytes | 298,810 bytes | 2,395,876 bytes | +| [B2.4] Concurrently insert & delete (encodeTime) | 6 ms | 3 ms | 14 ms | 4 ms | 43 ms | 56 ms | +| [B2.4] Concurrently insert & delete (docSize) | 282,112 bytes | 282,358 bytes | 392,151 bytes | 304,592 bytes | 293,831 bytes | 307,291 bytes | +| [B2.4] Concurrently insert & delete (parseTime) | 105 ms | 73 ms | 34 ms | 31 ms | 185 ms | 269 ms | +| [B3.1] 20√N clients concurrently set number in Map (time) | 54 ms | 60 ms | 27 ms | 32 ms | 1,058 ms | 21 ms | +| [B3.1] 20√N clients concurrently set number in Map (updateSize) | 49,167 bytes | 49,162 bytes | 132,376 bytes | 63,832 bytes | 283,296 bytes | 283,296 bytes | +| [B3.1] 20√N clients concurrently set number in Map (encodeTime) | 3 ms | 1 ms | 16 ms | 1 ms | 9 ms | 12 ms | +| [B3.1] 20√N clients concurrently set number in Map (docSize) | 36,763 bytes | 36,751 bytes | 78,764 bytes | 38,428 bytes | 86,165 bytes | 86,164 bytes | +| [B3.1] 20√N clients concurrently set number in Map (parseTime) | 57 ms | 67 ms | 83 ms | 49 ms | 59 ms | 54 ms | +| [B3.2] 20√N clients concurrently set Object in Map (time) | 55 ms | 62 ms | 27 ms | 40 ms | 1,126 ms | 28 ms | +| [B3.2] 20√N clients concurrently set Object in Map (updateSize) | 85,084 bytes | 85,073 bytes | 171,370 bytes | 99,753 bytes | 398,090 bytes | 325,370 bytes | +| [B3.2] 20√N clients concurrently set Object in Map (encodeTime) | 2 ms | 1 ms | 18 ms | 2 ms | 21 ms | 17 ms | +| [B3.2] 20√N clients concurrently set Object in Map (docSize) | 72,682 bytes | 72,659 bytes | 84,255 bytes | 75,227 bytes | 112,588 bytes | 93,401 bytes | +| [B3.2] 20√N clients concurrently set Object in Map (parseTime) | 73 ms | 68 ms | 83 ms | 48 ms | 68 ms | 59 ms | +| [B3.3] 20√N clients concurrently set String in Map (time) | 124 ms | 61 ms | 63 ms | 73 ms | 1,869 ms | 166 ms | +| [B3.3] 20√N clients concurrently set String in Map (updateSize) | 7,826,229 bytes | 7,826,225 bytes | 7,912,520 bytes | 7,840,917 bytes | 8,063,440 bytes | 8,063,440 bytes | +| [B3.3] 20√N clients concurrently set String in Map (encodeTime) | 56 ms | 5 ms | 70 ms | 23 ms | 59 ms | 61 ms | +| [B3.3] 20√N clients concurrently set String in Map (docSize) | 7,813,826 bytes | 7,813,814 bytes | 241,646 bytes | 7,815,537 bytes | 97,997 bytes | 98,008 bytes | +| [B3.3] 20√N clients concurrently set String in Map (parseTime) | 141 ms | 75 ms | 161 ms | 44 ms | 80 ms | 76 ms | +| [B3.4] 20√N clients concurrently insert text in Array (time) | 45 ms | 49 ms | 195 ms | 28 ms | 1,768 ms | 19 ms | +| [B3.4] 20√N clients concurrently insert text in Array (updateSize) | 52,751 bytes | 52,735 bytes | 137,490 bytes | 70,514 bytes | 311,830 bytes | 285,330 bytes | +| [B3.4] 20√N clients concurrently insert text in Array (encodeTime) | 1 ms | 0 ms | 13 ms | 1 ms | 13 ms | 7 ms | +| [B3.4] 20√N clients concurrently insert text in Array (docSize) | 26,596 bytes | 26,580 bytes | 100,791 bytes | 47,943 bytes | 96,423 bytes | 86,519 bytes | +| [B3.4] 20√N clients concurrently insert text in Array (parseTime) | 55 ms | 37 ms | 94 ms | 30 ms | 43 ms | 37 ms | +| [B4] Apply real-world editing dataset (time) | 2,616 ms | 17,556 ms | 2,271 ms | 768 ms | 7,109 ms | 2,775 ms | +| [B4] Apply real-world editing dataset (encodeTime) | 4 ms | 8 ms | 11 ms | 3 ms | 165 ms | 161 ms | +| [B4] Apply real-world editing dataset (docSize) | 226,981 bytes | 226,981 bytes | 230,556 bytes | 260,813 bytes | 129,116 bytes | 129,116 bytes | +| [B4] Apply real-world editing dataset (parseTime) | 27 ms | 22 ms | 6 ms | 4 ms | 1,185 ms | 1,123 ms | +| [B4x100] Apply real-world editing dataset 100 times (time) | 279,705 ms | skipped | 233,739 ms | 75,122 ms | skipped | skipped | +| [B4x100] Apply real-world editing dataset 100 times (encodeTime) | 417 ms | skipped | 743 ms | 205 ms | skipped | skipped | +| [B4x100] Apply real-world editing dataset 100 times (docSize) | 22,694,543 bytes | skipped | 21,016,454 bytes | 26,826,427 bytes | skipped | skipped | +| [B4x100] Apply real-world editing dataset 100 times (parseTime) | 1,270 ms | skipped | 66 ms | 200 ms | skipped | skipped | +| [B3.5] 20√N clients concurrently insert text (time) | 42 ms | 49 ms | 212 ms | 115 ms | 1,869 ms | 36 ms | +| [B3.5] 20√N clients concurrently insert text (updateSize) | 48,129 bytes | 48,135 bytes | 132,870 bytes | 68,978 bytes | 298,020 bytes | 298,020 bytes | +| [B3.5] 20√N clients concurrently insert text (encodeTime) | 1 ms | 0 ms | 13 ms | 1 ms | 12 ms | 16 ms | +| [B3.5] 20√N clients concurrently insert text (docSize) | 24,313 bytes | 24,325 bytes | 102,818 bytes | 48,053 bytes | 90,768 bytes | 90,781 bytes | +| [B3.5] 20√N clients concurrently insert text (parseTime) | 38 ms | 38 ms | 64 ms | 63 ms | 67 ms | 55 ms | +| [C1.1] Concurrently insert & delete 100K (time) | 27,138 ms | skipped | 2,335 ms | skipped | 50,692 ms | skipped | +| [C1.1] Concurrently insert & delete 100K (updateSize) | 4,908,122 bytes | skipped | 4,269,521 bytes | skipped | 10,326,487 bytes | skipped | +| [C1.1] Concurrently insert & delete 100K (encodeTime) | 129 ms | skipped | 210 ms | skipped | 837 ms | skipped | +| [C1.1] Concurrently insert & delete 100K (docSize) | 4,908,186 bytes | skipped | 6,748,052 bytes | skipped | 5,404,289 bytes | skipped | +| [C1.1] Concurrently insert & delete 100K (parseTime) | 1,653 ms | skipped | 78 ms | skipped | 7,308 ms | skipped | +| [C1.1] Concurrently insert & delete 100K (versionSize) | 222,681 bytes | skipped | 28 bytes | skipped | 64 bytes | skipped | +| [C1.2] Concurrently set Map 100K (time) | 31,598 ms | skipped | 488 ms | skipped | 472,547 ms | skipped | +| [C1.2] Concurrently set Map 100K (updateSize) | 980,804 bytes | skipped | 2,601,744 bytes | skipped | 5,541,744 bytes | skipped | +| [C1.2] Concurrently set Map 100K (encodeTime) | 60 ms | skipped | 203 ms | skipped | 2,652 ms | skipped | +| [C1.2] Concurrently set Map 100K (docSize) | 738,949 bytes | skipped | 1,539,209 bytes | skipped | 1,743,081 bytes | skipped | +| [C1.2] Concurrently set Map 100K (parseTime) | 4,069 ms | skipped | 631 ms | skipped | 338 ms | skipped | +| [C1.2] Concurrently set Map 100K (versionSize) | 416,246 bytes | skipped | 314,810 bytes | skipped | 1,949,999 bytes | skipped | + + +# FILE: pages/docs/performance/docsize.md + +--- +keywords: "loro, yjs, automerge, diamond-type, benchmark, document size, crdt" +description: "Comparing the document size of Loro and popular CRDTs" +--- + +# Document Size + +In this benchmark, we use the Automerge paper dataset. + +Source: https://github.com/automerge/automerge-perf/tree/master/edit-by-index + +The dataset consists of: + +- 182,315 single-character insertion operations +- 77,463 single-character deletion operations +- A total of 259,778 operations +- 104,852 characters in the final document + +The first line of settings in the table below indicates configurations without +`gc` and `compress`. + +| Settings | loro-snapshot | loro-update | diamond-type | yrs | automerge | +| -------------------- | ------------- | ----------- | ------------ | ------ | --------- | +| Default (no options) | 273561 | 251352 | 281042 | 226973 | 292742 | +| gc | x | x | 203564 | 159921 | x | +| compress | 132459 | 105724 | 150723 | 91777 | 129062 | +| gc & compress | x | x | 106242 | 71033 | x | + +> The `x` in the table above signifies that the corresponding setting is not supported. + +Loro also supports a shallow snapshot encoding format with gc capabilities by truncating the history. For details, see [the doc](/docs/tutorial/encoding). +If truncated from the latest version, the result will be: + +| Settings | loro-shallow-snapshot | +| -------- | --------------------- | +| Default | 63352 | +| compress | 54517 | + + +# FILE: pages/docs/performance/native.mdx + +# Native Benchmarks + +[This native benchmark](https://github.com/zxch3n/crdt-bench-native) is based on +the Rust implementation of each crate. + +- Conducted on a M2 Max CPU, dated 2024-10-18. +- The tasks with names starting with `automerge` use the automerge paper + dataset. +- In this benchmark, compression is disabled for both automerge and loro. +- Diamond-type doesn't support the list type yet. + +| Tasks | automerge | loro | diamond-type | yrs | +| :---------------------- | :-------- | :-------- | :----------- | :-------- | +| automerge - apply | 450.91 ms | 88.19 ms | 15.63 ms | 4238.8 ms | +| automerge - decode time | 506.30 ms | 0.189 ms | 2.19 ms | 3.82 ms | +| automerge - encode time | 17.65 ms | 0.416 ms | 1.15 ms | 0.759 ms | +| concurrent list inserts | 81.07 ms | 130.63 ms | 57.08 ms | 13.95 ms | +| list_random_insert_1k | 296.64 ms | 12.15 ms | 4.32 ms | 5.83 ms | + + +# FILE: pages/docs/llm.md + +# LLM Resources + +- [llms.txt](/llms.txt) +- [llms-full.txt](/llms-full.txt) + + +# FILE: pages/docs/advanced/jsonpath.mdx + +--- +keywords: "jsonpath, querying, loro, crdt, advanced" +description: "Learn how to query Loro documents using RFC 9535 JSONPath support." +--- + +# JSONPath Queries + +[JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html) queries integrate +directly with Loro documents so you can traverse complex data without writing +ad hoc tree walkers. This guide outlines the query surface, highlights +supported syntax, and walks through examples that you can adapt to your own +application. + +> JSONPath functions such as `count()`, `length()`, `value()`, `match()`, and +> `search()` are parsed but not evaluated at runtime. Each currently returns +> `JsonPathError::EvaluationError::Unimplemented`. + +## Preparing Sample Data + +The snippets below use the WASM bindings, but the same structure works with the +Rust API. We create a bookstore dataset inside a `LoroDoc` so we can run queries +against it. + +> ⚠️ **Illustration only**: This approach stuffs plain JavaScript objects into a +> document for convenience. In production code prefer composing `LoroMap` and +> `LoroList` instances explicitly so you can control how data enters the CRDT and +> benefit from fine-grained updates. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const testData = { + books: [ + { title: "1984", author: "George Orwell", price: 10, available: true, isbn: "978-0451524935" }, + { title: "Animal Farm", author: "George Orwell", price: 8, available: true }, + { title: "Brave New World", author: "Aldous Huxley", price: 12, available: false }, + { title: "Fahrenheit 451", author: "Ray Bradbury", price: 9, available: true, isbn: "978-1451673318" }, + { title: "The Great Gatsby", author: "F. Scott Fitzgerald", price: null, available: true }, + { title: "To Kill a Mockingbird", author: "Harper Lee", price: 11, available: true }, + { title: "The Catcher in the Rye", author: "J.D. Salinger", price: 10, available: false }, + { title: "Lord of the Flies", author: "William Golding", price: 9, available: true }, + { title: "Pride and Prejudice", author: "Jane Austen", price: 7, available: true }, + { title: "The Hobbit", author: "J.R.R. Tolkien", price: 14, available: true } + ], + featured_author: "George Orwell", + min_price: 10, + featured_authors: ["George Orwell", "Jane Austen"], +}; + +const store = doc.getMap("store"); +Object.entries(testData).forEach(([key, value]) => store.set(key, value)); +doc.commit(); +``` + +## Executing JSONPath Queries + +Run a query with the `JSONPath` method on WASM or `jsonpath` on Rust. The API +returns a list of values for any matches. Container results currently surface as +handlers internally, but that interface is still evolving and treated as +opaque in this guide. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// setup omitted for brevity +const results = doc.JSONPath("$.store.books[*].title"); +console.log(results); +// => ["1984", "Animal Farm", ...] +``` + +```rust +use loro::prelude::*; + +let doc = LoroDoc::new(); +// setup omitted for brevity +let titles = doc.jsonpath("$.store.books[*].title")?; +for title in titles { + if let ValueOrHandler::Value(value) = title { + println!("{value}"); + } +} +``` + +### Supported Selectors and Filters + +RFC 9535 syntax works end-to-end, including: + +- **Selectors** – names, indices (positive/negative), slices, unions, wildcards, + and recursive descent (`..`). +- **Filters** – logical operators (`&&`, `||`, `!`), comparisons, membership via + `in`, substring checks with `contains`, property existence, and nested + subqueries through `$` (root) or `@` (current node). +- **Functions** – `count()`, `length()`, and `value()` are parsed, while `match()` + and `search()` are reserved for expansion. All functions currently return an + unimplemented evaluation error at runtime. + +## Cookbook Examples + +Once the document is populated you can combine selectors and filters to extract +precisely what you need. + +| Query | Description | Result | +| ----- | ----------- | ------ | +| `$.store.books[*].title` | All book titles. | `["1984", "Animal Farm", "Brave New World", "Fahrenheit 451", "The Great Gatsby", "To Kill a Mockingbird", "The Catcher in the Rye", "Lord of the Flies", "Pride and Prejudice", "The Hobbit"]` | +| `$.store.books[?(@.available)].title` | Titles of books in stock. | `["1984", "Animal Farm", "The Great Gatsby", "To Kill a Mockingbird", "Lord of the Flies", "Pride and Prejudice", "The Hobbit"]` | +| `$.store.books[?(@.author in $.store.featured_authors)].title` | Books by featured authors. | `["1984", "Animal Farm", "Pride and Prejudice"]` | +| `$.store.books[?(@.price > 12)].title` | Books priced above 12. | `["The Hobbit"]` | +| `$..price` | All price fields via recursive descent. | `[10, 8, 12, 9, null, 11, 10, 9, 7, 14]` | +| `$.store.books[0:3].title` | Slice syntax for the first three titles. | `["1984", "Animal Farm", "Brave New World"]` | +| `$.store.books[0,2,-1].title` | Union of specific indices. | `["1984", "Brave New World", "The Hobbit"]` | +| `count($.store.books[?(@.available)])` | Planned helper to count available books *(currently returns an unimplemented error)*. | `JsonPathError::EvaluationError::Unimplemented` | +| `length($.store.featured_authors)` | Planned array length helper *(currently returns an unimplemented error)*. | `JsonPathError::EvaluationError::Unimplemented` | +| `$.store.books[?(@.isbn && @.price >= $.store.min_price)].title` | Filter by field existence and comparison. | `["1984"]` | +| `$.store.books[?(!@.available)].title` | Negated availability filter. | `["Brave New World", "The Catcher in the Rye"]` | +| `$.store.books[?(@.title contains "The")].author` | Authors with "The" in the title. | `["F. Scott Fitzgerald", "J.R.R. Tolkien"]` | + +> JSONPath always returns values in document order. When a filter references +> another query (such as `$.store.featured_authors`), the subquery is evaluated +> for each candidate element. + + +# FILE: pages/docs/advanced/version_deep_dive.mdx + +--- +keywords: "crdt, version, frontier, DAG, git, history, time travel" +description: "The concept of several versions in Loro, DAG, Frontiers, and Version Vectors" +--- + +# Loro's Versioning Deep Dive: DAG, Frontiers, and Version Vectors + +In centralized environments, we can use linear version numbers to represent a +version, such as incrementing a number each time or using timestamps. However, +CRDTs can be used in decentralized environments, and their version +representation is different. + +In Loro, you can express a document's version through a +[Version Vector](https://en.wikipedia.org/wiki/Version_vector) or Frontiers. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +doc.version(); // State Version vector +doc.oplogVersion(); // OpLog Version vector +doc.frontiers(); // State Frontiers +doc.oplogFrontiers(); // OpLog Frontiers +``` + +In most cases, you might only need the Version Vector, which can be used for +data synchronization and version comparison. + +Frontiers are more space-efficient than Version Vectors but come with more +limitations. + +The following sections detail these two different ways of version description. + +## Background Knowledge + +Loro belongs to Operation-Based CRDTs. Two Loro documents are consistent if they +have the same set of activated Operations. Version is a compact way to describe +which ops belong to this set. + +Each operation has a unique ID, known as OpId. For example, each user's deletion +or insertion of characters will have a corresponding unique OpId (don't worry +about the overhead here; we've compressed them in memory and Encoding). OpId +consists of PeerId and Counter. + +> 💡 Note: To simplify, the specific type descriptions below may not be accurate +> (e.g., the type of peer), but this does not affect the understanding of the +> content. + +```ts +interface OpId { + // Simplified here + // PeerId is actually close to uuid type, Loro uses u64 + peerId: number; + counter: number; +} +``` + +PeerId represents the unique identifier of the device operating the document, +and Counter is a number that starts from 0 and increments on that device. + +## Version Vector + +A Version Vector is a vector that describes how many Operations starting from 0 +need to be included for each Peer. + +```ts +// In TypeScript +type VersionVector = { [peerId in number]: number }; +``` + +For example, for the following version vector A, + +```tsx +const A = { + 0: 2, + 1: 3, +}; +``` + +It includes: + +- All operations where PeerId == 0 && Counter < 2 +- Or PeerId == 1 && Counter < 3 + +This expression allows us to easily compare two versions and get the operations +they are missing from each other. For example, in the following case, suppose we +know that the document on the server has reached version B, and the device has +version A. To let the user's device get the Operations it is missing, + +```tsx +const A = { + 0: 2, + 1: 3, +}; + +const B = { + 0: 5, + 1: 3, + 2: 9, +}; +``` + +We can easily calculate the Operations the device is missing: + +- All Ops where PeerId == 0 && 2 ≤ Counter < 5 +- Or PeerId == 2 && Counter < 9 + +Version Vectors help us identify a version and allow us to quickly calculate the +differences between two versions (quickly calculating the set of Ops missing in +two documents to be synchronized). For example, currently in Loro, you can use +the following method to synchronize two documents, where they only import the +Ops missing from each other: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +const a = new LoroDoc(); +const b = new LoroDoc(); +// ---cut--- +// doc.version() returns the version vector of the doc +a.import(b.export({ mode: "update", from: a.version() })); +b.import(a.export({ mode: "update", from: b.version() })); +``` + +## Directed Acyclic Graph (DAG) History + +In Loro, we record an operation history similar to Git's Directed Acyclic Graph +(DAG). We represent DAG by recording Dependencies links on each Op. Like +document states, when the Op set is the same, the DAG is consistent across all +endpoints. + +![DAG Illustration](./images/version-3.png) + +When a new Op is added, its Dependencies link points to the current Operations +that are not being pointed to. + +> 💡 Note: The order of Ops is a partial order relationship. An Op may be +> before, after, or parallel to another Op. The structure of the DAG allows us +> to compactly express this partial order. + +DAG history information helps us design some special CRDT algorithms and +facilitates time-travel implementation. Moreover, when automatic merges don't +meet expectations, DAG allows users a better chance to manually resolve +potential conflicts. + +> 💡 We use Counter@PeerId for a more compact description of OpId. + +Below is an example of how a DAG is formed: + +![DAG Formation Example](./images/version-0.png) + +## Frontiers + +Version Vectors increase in size with the number of Peers. Most CRDTs Lib, to +prevent PeerId conflicts, use a random new PeerId each time a document is +opened, which increases the overhead of Version Vectors. + +> 📌 If PeerId repeats, there's a high probability of duplicate OpId, +> potentially leading to inconsistent CRDT documents. Automerge has solved this +> problem, ensuring document consistency even with duplicate OpId, but at the +> cost of higher sync complexity. + +Based on DAG information, we have a more compact way to describe versions: given +one or multiple OpIds, include all with a Causal Order less than or equal to +them. We call this method Frontiers. + +```ts twoslash +import { OpId } from "loro-crdt"; +// ---cut--- +type Frontiers = OpId[]; +``` + +> 💡 We use Counter@PeerId for a more compact description of OpId. + +![Frontiers Illustration](./images/version-1.png) + +`Frontiers = [3@0]` in the above DAG includes 6 operations: + +- PeerId == 0 && Counter < 4 +- PeerId == 1 && Counter < 2 + +It is equivalent to the Version Vector: + +```tsx +{ + 0: 4, + 1: 2, +} +``` + +Frontiers can also specify multiple parallel operations: + +![Parallel Operations Example](./images/version-2.png) + +In this example, `Frontiers = [1@1, 1@2]`, corresponding Version Vector is: + +```tsx +{ + 0: 1, + 1: 2, + 2: 2, +} +``` + +One great property of Frontiers as a version is that `Frontiers = [A]` actually +represents the document version right after operation A was executed. Thus, +combined with Loro's time machine capability, you can easily return to any +document version corresponding to a specific operation. Here is a complete +example: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId(0n); +const text = doc.getText("text"); +text.insert(0, "H"); +doc.commit(); +const v = doc.frontiers(); +text.insert(1, "i"); +expect(doc.toJSON()).toStrictEqual({ + text: "Hi", +}); + +doc.checkout([{ peer: "0", counter: 0 }]); +expect(doc.toJSON()).toStrictEqual({ + text: "H", +}); +``` + +You can convert between Version Vectors and Frontiers in Loro if the specified +version is included in the document's history: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +const f = doc.frontiers(); +const vv = doc.frontiersToVV(f); +// vv equals to doc.version() +const frontiers = doc.vvToFrontiers(vv); +``` + + +# FILE: pages/docs/advanced/undo.mdx + +--- +keywords: "crdt, undo, redo, undo manager, cursor, selection, collaborate" +description: "how to use loro undo manager and show all APIs of loro undo manager." +--- + +# Undo + +We provide an `UndoManager` that helps you manage the undo/redo stack and can be +used during concurrent editing. It also supports cursor position transformation. +In the case of concurrent editing, you will want the undo/redo to be **local**, +meaning it should only undo/redo local operations without affecting other +collaborators' edits. + +### Why Local Undo/Redo? + +If undo/redo is global, it often does not meet expectations. Consider the +following scenario: + +- **User A and User B are collaborating**: They are likely editing different + parts of the document. +- **User A performs an undo**: If this undoes User B's operations, User A might + see no changes and think the undo failed, as User B might be editing content + outside of User A's view. +- **User B's perspective**: User B would find their recent edits deleted without + knowing how it happened. + +## Usage + +To create an `UndoManager`, you can specify: + +- Which local operations are not recorded. +- The merge range for undo operations. +- The stack depth. + +```ts twoslash +import { UndoManager, LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +const undoManager = new UndoManager(doc, { + maxUndoSteps: 100, // default 100 + mergeInterval: 1000, // default 1000ms + excludeOriginPrefixes: ["sys:"], // default [] + onPush: (isUndo, range, event) => { + return { value: null, cursors: [] }; + }, + onPop: (isUndo, value, counterRange) => { + return; + }, +}); +``` + +## Limitations + +It can only track a single peer. When the peer ID of the document changes, it will +clear the undo stack and the redo stack and track the new peer ID. + +## Restoring Selections + +When utilizing undo/redo functionalities, it is important for our application to +restore the selection to its position prior to the operation that is being +undone or redone. This task is particularly challenging in a collaborative +setting where remote operations can alter the cursor's position (for instance, +if the cursor needs to revert to the 5th position, but remote operations have +added new characters before this position). + +
+Challenges + +- **Local undo/redo**: Local undo and redo delete and recreate elements. If we + use CRDT-based stable positions, they might lock on to the deleted elements, + while the user's expectation is for the cursor to return to the newly created + elements after redo. +- **Example**: + ``` + A fox jumped + ``` + - Select "fox" and delete it. + - Undo. + - After undo, the three characters "fox" should be recreated, and the cursor + should select these three characters. However, if we use stable positions, + it would lock onto the initially deleted characters, and after undo, the + absolute positions obtained would be `start = 2` and `end = 2`. + +
+ +### Solution + +We support storing [`cursors`](/docs/tutorial/cursor) for each undo/redo action +within the `UndoManager`. The `UndoManager` will adjust the stored cursors to +reflect changes from remote edits or other undo/redo operations, ensuring they +match the current state of the document. + +They need be handled by the `onPush` and `onPop` callbacks. + +On `onPush`, you can return a list of `Cursor`s that you want to store. On +`onPop`, you can retrieve the stored cursors and use them to restore the +selection. + +Typically, you may need to store selections in an undo/redo item in the +following cases: + +- When a new local change is applied, we need to record a new undo item. + Therefore, we must store the selection **before** the operation to be undone. + - **Purpose**: Storing the selection **before** is crucial because we may lose + the selection after applying the operation. If the user selects text and + deletes it, after undo, the `onPop` can retrieve the state of the selected + deleted content. +- **First undo operation**: Store the current document's selection for the + corresponding redo item. + - **Purpose**: After redo, it can return to the initial selection state. + +Internally, we also automatically handle the storage and reset of cursors in the +undo/redo loop state. + +```ts twoslash +import { LoroDoc, UndoManager, Cursor } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +let cursors: Cursor[] = []; +let poppedCursors: Cursor[] = []; +const undo = new UndoManager(doc, { + mergeInterval: 0, + onPop: (isUndo, value, counterRange) => { + poppedCursors = value.cursors; + }, + onPush: () => { + return { value: null, cursors: cursors }; + }, +}); + +doc.getText("text").insert(0, "hello world"); +doc.commit(); +cursors = [ + doc.getText("text").getCursor(0)!, + doc.getText("text").getCursor(5)!, +]; +// delete "hello ", the cursors should be transformed +doc.getText("text").delete(0, 6); +doc.commit(); +expect(poppedCursors.length).toBe(0); +undo.undo(); +expect(poppedCursors.length).toBe(2); +expect(doc.toJSON()).toStrictEqual({ + text: "hello world", +}); +// expect the cursors to be transformed back +expect(doc.getCursorPos(poppedCursors[0]).offset).toBe(0); +expect(doc.getCursorPos(poppedCursors[1]).offset).toBe(5); +``` + +
+Implementation + +[The implementation of undo/redo](https://github.com/loro-dev/loro/pull/361) +follows a model similar to ProseMirror/Quill, which are based on OT (Operational +Transformation) algorithms (so we also implement basic OT primitives +internally). + +When implementing undo/redo operations, we need to ensure the following +properties: + +- Do not undo remote inserts. +- Redo after undo should return to the original state. +- If there is no concurrent editing, undo should return to the previous + version's state. + +Therefore, we have also added some relevant checks in our internal fuzzing tests +to ensure correctness. + +
+ +## Demonstration + +import { ReactPlayer } from "../../../components/video"; +import Caption from "../../../components/caption"; + +{" "} + + + +ProseMirror with Loro binding + +## Understanding the Undo/Redo Stack + +The UndoManager maintains two stacks: + +1. **Undo Stack**: Contains operations that can be undone +2. **Redo Stack**: Contains operations that were undone and can be redone + +### How the Callbacks Work + +The `onPush` and `onPop` callbacks are triggered when these stacks change: + +- **onPush(isUndo, range, event)**: Called when a new item is pushed to either stack + - `isUndo: boolean`: `true` for the undo stack, `false` for the redo stack + - `range: (number, number)`: The operations' counter range that associated with the undo/redo action + - Returns: An object that can include `value` (any data you want to store) and `cursors` (cursor positions) + +- **onPop(isUndo, value)**: Called when an item is popped from either stack + - `isUndo`: `true` for the undo stack, `false` for the redo stack + - `value`: The value you returned from `onPush` when this item was created + +### Understanding Action Merging + +The `mergeInterval` option in the UndoManager controls how closely spaced operations are grouped: + +```ts twoslash no_run +import { UndoManager, LoroDoc } from "loro-crdt"; +// ---cut--- +declare const doc: LoroDoc; +const undoManager = new UndoManager(doc, { + mergeInterval: 1000, // 1000ms = 1 second (default) +}); +``` + +**How mergeInterval works:** + +- Operations occurring within the specified time interval (in milliseconds) will be merged into a single undo action +- Even though these operations are merged, `onPush` events will still be triggered for each individual operation +- When undoing, all merged operations will be undone as a single unit +- A lower value results in more granular undo steps; a higher value creates fewer, more comprehensive undo steps +- Set to `0` to disable merging entirely (every operation becomes a separate undo step) + +### Stack Operations Flow + +1. When a local transaction is committed, a new undo item is pushed to the undo stack (triggers `onPush` with `isUndo=true`) +2. When `.undo()` is called: + - An item is popped from the undo stack (triggers `onPop` with `isUndo=true`) + - A corresponding item is pushed to the redo stack (triggers `onPush` with `isUndo=false`) +3. When `.redo()` is called: + - An item is popped from the redo stack (triggers `onPop` with `isUndo=false`) + - A corresponding item is pushed to the undo stack (triggers `onPush` with `isUndo=true`) + +### Manual Grouping with `groupStart` and `groupEnd` + +Sometimes you need tighter control over how undo units are formed than `mergeInterval` alone can provide. The `UndoManager.groupStart()` and `UndoManager.groupEnd()` methods let you explicitly wrap a sequence of commits so they become a single undo step. + +- Call `groupStart()` before you begin a batch of related edits. Any commits that happen before the matching `groupEnd()` will undo together. +- Calling `groupStart()` while another group is active throws an error and leaves the original group in place, which helps catch accidental nesting. +- Remote updates can influence grouping: a conflicting import may force the current group to end early, while non-conflicting imports are merged without breaking the group. Always finish with `groupEnd()`—if the group already auto-closed, the call becomes a no-op. + +```ts twoslash no_run +import { LoroDoc, UndoManager } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +const undoManager = new UndoManager(doc, {}); + +undoManager.groupStart(); + +text.update("hello", undefined); +doc.commit(); +text.update("hello world", undefined); +doc.commit(); + +undoManager.groupEnd(); + +undoManager.undo(); // Reverts both commits in one step +``` + +### Example: Text Editing with Undo/Redo + +Consider a simple text editor that uses Loro for collaboration. Let's walk through what happens during typical editing operations: + +```ts twoslash no_run +import { LoroDoc, UndoManager } from "loro-crdt"; +// ---cut--- +// Create document and undo manager +const doc = new LoroDoc(); +const textField = doc.getText("textField"); + +// Set up undo manager with callbacks to track changes +const undoManager = new UndoManager(doc, { + // Store cursor positions and any other state you need + onPush: (isUndo, range) => { + if (isUndo) { + console.log("Action recorded for undo"); + } else { + console.log("Action recorded for redo"); + } + // Return whatever data you want associated with this action + return { + value: { affectedRange: range }, + cursors: [/* your cursor positions */] + }; + }, + + onPop: (isUndo, storedData) => { + // Access the data you stored during onPush + const { value, cursors } = storedData; + + if (isUndo) { + console.log("Retrieving data for undo"); + } else { + console.log("Retrieving data for redo"); + } + + // Use the stored cursors to restore selection + // applyStoredCursors(cursors); + }, + + mergeInterval: 0, +}); + +// User types "Hello" +textField.insert(0, "Hello"); +doc.commit(); +// → onPush triggered (isUndo=true) - Adds to undo stack + +// User types " World" +textField.insert(5, " World"); +doc.commit(); +// → onPush triggered (isUndo=true) - Adds to undo stack + +// User clicks Undo button +undoManager.undo(); +// → onPop triggered (isUndo=true) - Removes " World" from document +// → onPush triggered (isUndo=false) - Adds to redo stack + +// Document now contains only "Hello" + +// User clicks Redo button +undoManager.redo(); +// → onPop triggered (isUndo=false) - Retrieves " World" operation +// → onPush triggered (isUndo=true) - Adds back to undo stack + +// Document now contains "Hello World" again +``` + +When the user clicks Undo, two things happen: +1. The last action is popped from the undo stack (removing " World") +2. That action is pushed to the redo stack so it can be redone later + +This approach ensures that local changes can be undone without affecting other users' edits, making it ideal for collaborative editing. + +### Cursor Efficiency + +The built-in cursor solution is optimized for performance and handles collaborative scenarios efficiently, including situations where peers may +change the document concurrently during undo/redo operations. For complex editors like rich text editors, the cursor implementation provides the +best balance of performance and correctness. + + +# FILE: pages/docs/advanced/inspector.mdx + +# Loro Inspector + +[Loro Inspector](https://inspector.loro.dev/) is an open-source web tool that helps developers debug and visualize Loro documents. It provides a user-friendly interface to inspect the state and history of Loro documents. + +import { ReactPlayer } from "../../../components/video"; + + + + +# FILE: pages/docs/advanced/import_batch.mdx + +# Batch Import + +## Performance Differences and Their Causes + +When importing multiple updates into a document, using `doc.importBatch(updates)` is significantly faster than importing updates individually. This performance difference stems from how data merging is handled in each approach. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").update("Hello"); +const update1 = doc.export({ mode: "update" }); +const version = doc.version(); +doc.getText("text").update("Hello World"); +const update2 = doc.export({ mode: "update", from: version }); + +const newDoc1 = new LoroDoc(); +newDoc1.importBatch([update1, update2]); // faster + +const newDoc2 = new LoroDoc(); +for (const update of [update1, update2]) { // slower + newDoc2.import(update); +} +``` + +### Key Advantages of Import Batch + +#### 1. Single Diff Calculation + +The most significant advantage is that import batch performs only one diff calculation. In contrast, each individual import follows these steps: + +- Merge remote updates into local history +- Calculate document state changes from the current version to the merged version +- Apply the diff to the current document state + +This diff calculation has fixed overhead costs that accumulate with each import. But `doc.importBatch(...)` only performs one diff calculation, which is faster than multiple individual diff calculations. + +#### 2. Reduced Communication Overhead + +Import batch also results in more concise events. Each individual import generates a new event, but `doc.importBatch(...)` generates only a single event that contains all the changes. + + +# FILE: pages/docs/advanced/timestamp.mdx + +# Storing Timestamps + +You can enable timestamp recording through `setRecordTimestamp`, allowing Unix timestamps to be logged in each `Change`. Consequently, these timestamps will be preserved in exported `Updates` or `Snapshots`. + +Enabling this feature affects the merge behavior of `Changes`, as `Changes` with too long a time gap cannot share the same timestamp. In such cases, you can adjust the mergeable duration range using `setChangeMergeInterval`, with a default setting of 1,000,000, equating to a 1000s threshold for merging `Changes`. + +> Each insertion or deletion by the user generates an `op`, and multiple consecutive `op`s can merge into a larger `Change`. `Change`s log a `Timestamp`, but each `Change` can only associate with one `Timestamp`. Hence, if the time gap is too wide, merging `Changes` becomes impractical. However, treating each Change as new based on slight timestamp differences (e.g., key presses milliseconds apart) introduces significant additional overhead for each Change. Therefore, users can customize the `change merge interval` according to their needs. + +Note that these settings do not persist in exported `Updates` or `Snapshots`. Thus, if custom configurations are required, they must be reapplied upon each initialization of `LoroDoc`. Without timestamp recording enabled, the `Timestamp` defaults to the current maximum known `Timestamp`. + + +# FILE: pages/docs/advanced/cid.mdx + +# Container ID + +A Container ID is a unique identifier that comes in two forms: + +- Root Container: Composed of a type and root name +- Normal Container: Created through user operations, composed of an ID and type + +**Rust `ContainerID`** + +```rust +pub enum ContainerID { + Root { + name: InternalString, + container_type: ContainerType, + }, + Normal { + peer: PeerID, + counter: Counter, + container_type: ContainerType, + }, +} +``` + +**TypeScript `ContainerID`** + +```ts twoslash +import type { ContainerType, PeerID } from "loro-crdt"; +// ---cut--- +export type ContainerID = + | `cid:root-${string}:${ContainerType}` + | `cid:${number}@${PeerID}:${ContainerType}`; +``` + +1. **Root Containers** + - Created implicitly when accessing a root container for the first time + (e.g., calling `doc.getText("text")`). No operation is generated in the + history. + - Uniquely identified by a string name and container type + +2. **Normal Containers** + - Created explicitly through operations like `insertContainer` or + `createNode` + - Generated automatically when applying operations that create child + containers + - Contains the Operation ID of its creation within its Container ID + +3. **Mergeable Containers** + - Created through `LoroMap.ensureMergeableText`, + `ensureMergeableMap`, `ensureMergeableList`, and the other + `ensureMergeable*` methods + - Use a special form of Root Container ID. The stable name inside that ID is + derived from the logical position `(parent Map, key, container type)`, not + from the operation that first created the child + - Useful when multiple peers may lazily initialize the same child container + under the same Map key + +## Container States and IDs + +The ContainerID is not a random UUID but is deterministically generated based on the container's context. To understand how ContainerIDs work, it's important to first understand container states. + +> For a comprehensive guide on containers, including attached vs detached states, see [Container Concepts](/docs/concepts/container). + +Key points about ContainerID generation: +- **Root containers**: Derive their ID from their name (e.g., "text" in `doc.getText("text")`) +- **Normal containers**: Derive their ID from the operation (OpID) that created them +- **Mergeable containers**: Use a special Root Container ID whose stable name is derived from the parent Map, key, and container type +- **Detached containers**: Have a default placeholder ID until they're inserted into a document + +## Container Overwrites + +When initializing child containers in parallel, overwrites can occur instead of +automatic merging. For example: + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const a: string = "hello"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Parallel initialization of child containers +const docB = doc.fork(); +const textA = doc.getMap("map").setContainer("text", new LoroText()); +textA.insert(0, "A"); +const textB = docB.getMap("map").setContainer("text", new LoroText()); +textB.insert(0, "B"); + +doc.import(docB.export({ mode: "update" })); +// Result: Either { "meta": { "text": "A" } } or { "meta": { "text": "B" } } +``` + +This behavior poses a significant risk of data loss if the editing history is +not preserved. Even when the complete history is available and allows for data +recovery, the recovery process can be complex. + + + +When a container holds substantial data or serves as the primary storage for +document content, overwriting it can lead to the unintended hiding/loss of +critical information. For this reason, it is essential to implement careful and +systematic container initialization practices to prevent such issues. + +### Mergeable Containers + +The overwrite behavior occurs because parallel creation of regular child +containers results in different container IDs, preventing automatic merging of +their contents. If peers should address the same child by logical Map key, use a +mergeable child container: + +```ts no_run +const text = doc.getMap("map").ensureMergeableText("text"); +``` + +This uses a special Root Container ID for the child. Its stable name is derived +from the parent Map, key, and type, rather than from the operation that first +created it. + +### Best Practices + +1. Use `ensureMergeable*` when a Map child container should be shared by logical + key even if peers initialize it concurrently. + +2. When the structure is fixed and known ahead of time: + - If possible, initialize all child containers during the map container's + initialization + +3. Use root containers when the child naturally has a stable top-level name. + +4. Use regular child containers when each creation should have a distinct + operation-derived identity. + + +# FILE: pages/docs/api/js.mdx + +import styles from "./api-reference.module.css"; +import Indent from "./indent"; +import Method from "./method"; + +
+ +# API Reference + +> _Last updated: 2026-06-16 loro-crdt@1.13.3_ + +## Overview + +Loro is a powerful Conflict-free Replicated Data Type (CRDT) library that enables real-time collaboration. If CRDTs are new to you, start with [What are CRDTs](/docs/concepts/crdt) for a gentle intro. This API reference provides comprehensive documentation for all classes, methods, and types available in the JavaScript/TypeScript binding. + +Note: Under the hood, Loro combines a Fugue-based CRDT core with Eg-walker-inspired techniques that use simple index operations and replay only the divergent history when merging. This yields fast local edits, efficient merges, and low overhead without permanent tombstones. See the primer [Event Graph Walker (Eg-walker)](/docs/advanced/event_graph_walker) and performance notes in the v1.0 blog (import/export speedups, shallow snapshots): https://loro.dev/blog/v1.0 + +## Pitfalls & Best Practices + +**Peer ID Management** + +- **Never share PeerIDs** between concurrent sessions (tabs/devices) - causes document divergence +- Use random PeerIDs (default) unless you have strict single-ownership locking +- Don't assign fixed PeerIDs to users or devices + +**UTF-16 Text Encoding** + +- All text operations use UTF-16 indices by default in JS API +- Slicing in the middle of multi-unit codepoints corrupts them +- Use `insertUtf8()`/`deleteUtf8()` for UTF-8 systems + +**Container Creation** + +- Concurrent child container creation inside the same LoroMap at same key causes overwrites +- Initialize all child containers for a LoroMap upfront when possible +- Operations on the root containers will not override each other + +- Events emit synchronously during commit/import/checkout in JS API (v1.8+). Stay on `<=1.7.x`? Await a microtask before reading batched events. +- Import/export/checkout trigger automatic commits +- Loro transactions are NOT ACID - no rollback/isolation + +**Version Control** + +- After `checkout()`, document enters read-only "detached" mode, unless `setDetachedEditing(true)` is called +- [Frontiers](/docs/concepts/frontiers) can't determine complete operation sets without history + +**Data Structure Choice** + +- Use strings in Map for URLs/IDs (LWW), LoroText for collaborative editing + +## Common Tasks & Examples + +**Getting Started** + +- **Create a document**: [`new LoroDoc()`](#LoroDoc.constructor) - Initialize a new collaborative document +- **Add containers**: [`getText`](#LoroDoc.getText), [`getList`](#LoroDoc.getList), [`getMap`](#LoroDoc.getMap), [`getTree`](#LoroDoc.getTree) +- **Listen to changes**: [`subscribe`](#LoroDoc.subscribe) - React to document modifications +- **Export/Import state**: [`export`](#LoroDoc.export) and [`import`](#LoroDoc.import) - Save and load documents + +**Real-time Collaboration** + +- **Sync between peers**: [`export`](#LoroDoc.export) with `mode: "update"` + [`import`](#LoroDoc.import)/[`importBatch`](#LoroDoc.importBatch) - Exchange incremental updates +- **Stream updates**: [`subscribeLocalUpdates`](#LoroDoc.subscribeLocalUpdates) - Send changes over WebSocket/WebRTC +- **Set unique peer ID**: [`setPeerId`](#LoroDoc.setPeerId) - Ensure each client has a unique identifier +- **Handle conflicts**: Automatic - All Loro data types are CRDTs that merge concurrent edits + +**Rich Text Editing** + +- **Create rich text**: [`getText`](#LoroDoc.getText) - Initialize a collaborative text container +- **Edit text**: [`insert`](#LoroText.insert), [`delete`](#LoroText.delete), [`applyDelta`](#LoroText.applyDelta) +- **Apply formatting**: [`mark`](#LoroText.mark) - Add bold, italic, links, custom styles +- **Copy styled snippets**: [`sliceDelta`](#LoroText.sliceDelta) - Get a Delta for a range (UTF-16; use `sliceDeltaUtf8` for byte offsets) +- **Track cursor positions**: [`getCursor`](#LoroText.getCursor) + [`getCursorPos`](#LoroDoc.getCursorPos) - Stable positions across edits +- **Configure styles**: [`configTextStyle`](#LoroDoc.configTextStyle) - Define expand behavior for marks + +**Data Structures** + +- **Ordered lists**: [`getList`](#LoroDoc.getList) - Arrays with [`push`](#LoroList.push), [`insert`](#LoroList.insert), [`delete`](#LoroList.delete) +- **Key-value maps**: [`getMap`](#LoroDoc.getMap) - Objects with [`set`](#LoroMap.set), [`get`](#LoroMap.get), [`delete`](#LoroMap.delete) +- **Hierarchical trees**: [`getTree`](#LoroDoc.getTree) - File systems, nested comments with [`createNode`](#LoroTree.createNode), [`move`](#LoroTree.move) +- **Reorderable lists**: [`getMovableList`](#LoroDoc.getMovableList) - Drag-and-drop with [`move`](#LoroMovableList.move), [`set`](#LoroMovableList.set) +- **Counters**: [`getCounter`](#LoroDoc.getCounter) - Distributed counters with [`increment`](#LoroCounter.increment) + +**Ephemeral State & Presence** + +- **User presence**: [`EphemeralStore`](#ephemeralstore) - Share cursor positions, selections, user status (not persisted) +- **Cursor syncing**: Use [`EphemeralStore.set`](#EphemeralStore.set) with cursor data from [`getCursor`](#LoroText.getCursor) +- **Live indicators**: Track who's online, typing indicators, mouse positions +- **Important**: EphemeralStore is a separate CRDT without history - perfect for temporary state that shouldn't persist + +**Version Control & History** + +- **Undo/redo**: [`UndoManager`](#undomanager) - Local undo of user's own edits +- **Time travel**: [`checkout`](#LoroDoc.checkout) to any [`frontiers`](#LoroDoc.frontiers) - Debug or review history +- **Version tracking**: [`version`](#LoroDoc.version), [`frontiers`](#LoroDoc.frontiers), [`versionVector`](#LoroDoc.versionVector) +- **Fork documents**: [`fork`](#LoroDoc.fork) or [`forkAt`](#LoroDoc.forkAt) - Create branches for experimentation +- **Merge branches**: [`import`](#LoroDoc.import) - Combine changes from forked documents + +**Performance & Storage** + +- **Incremental updates**: [`export`](#LoroDoc.export) from specific [`version`](#LoroDoc.version) - Send only changes +- **Compact history**: [`export`](#LoroDoc.export) with `mode: "snapshot"` - Full state with compressed history +- **Shallow snapshots**: [`export`](#LoroDoc.export) with `mode: "shallow-snapshot"` - State without partial history (see [Shallow Snapshots](/docs/concepts/shallow_snapshots)) + +## Basic Usage + +```typescript twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Subscribe to changes +const unsubscribe = doc.subscribe((event) => { + console.log("Document changed:", event); +}); + +// Export updates for synchronization +const updates = doc.export({ mode: "update" }); +``` + +## LoroDoc + +The `LoroDoc` class manages containers, sync, versions, and events. + +**Constructor** + +```typescript no_run +new LoroDoc(); +``` + +Creates a new Loro document with a randomly generated peer ID. + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +``` + +**Static Methods** + + +```typescript no_run +static fromSnapshot(snapshot: Uint8Array): LoroDoc +``` + + +Creates a new LoroDoc from a snapshot. This is useful for loading a document from a previously exported snapshot. + +**Parameters:** + +- `snapshot` - Binary snapshot data + +**Returns:** A new LoroDoc instance + +**Example:** + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; + +// Assume we have a snapshot from a previous export +const prevDoc = new LoroDoc(); +prevDoc.getText("text").insert(0, "Hello"); +const snapshot: Uint8Array = prevDoc.export({ mode: "snapshot" }); + +const doc = LoroDoc.fromSnapshot(snapshot); +``` + + + +**Properties** + + +```typescript no_run +readonly peerId: bigint +``` + + +Gets the peer ID of the current writer as a bigint. + +**See Also:** [PeerID Management](/docs/concepts/peerid_management) + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const id = doc.peerId; +``` + + + + +```typescript no_run +readonly peerIdStr: `${number}` +``` + + +Gets the peer ID as a decimal string. + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const idStr = doc.peerIdStr; +``` + + + +### Configuration Methods + + +```typescript no_run +setPeerId(peer: number | bigint | `${number}`): void +``` + + +Sets the peer ID for this document. It must be a number, a BigInt, or a decimal string that fits into an unsigned 64-bit integer. +See [PeerID Management](/docs/concepts/peerid_management) for why uniqueness matters in distributed systems. + +**Parameters:** + +- `peer` - Peer ID as number, bigint, or decimal string + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setPeerId("42"); +``` + +**⚠️ Critical Pitfall:** Never let two parallel peers (e.g., multiple tabs/devices) share the same PeerID — it creates duplicate op IDs and causes document divergence. Common mistakes: + +- Don't assign a fixed PeerId to a user (users have multiple devices) +- Don't assign a fixed PeerId to a device (multiple tabs can open the same document) +- If you must reuse PeerIDs, enforce single ownership with strict locking mechanisms +- Best practice: Use random IDs (default behavior) unless you have a strong reason not to + +See [PeerID reuse](/docs/tutorial/tips) for safe reuse patterns. + + + + +```typescript no_run +setRecordTimestamp(auto_record: boolean): void +``` + + +Configures whether to automatically record timestamps for changes. Timestamps use Unix time (seconds since epoch). Learn more about storing timestamps and typical use cases in [Storing Timestamps](/docs/advanced/timestamp). + +**Parameters:** + +- `auto_record` - Whether to automatically record timestamps + +**⚠️ Important:** This setting doesn't persist in exported Updates or Snapshots. You must reapply this configuration each time you initialize a document. + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setRecordTimestamp(true); +``` + + + + +```typescript no_run +setChangeMergeInterval(interval: number): void +``` + + +Sets the interval in milliseconds for merging continuous local changes into a single change record. In Loro, multiple low-level operations are grouped into higher-level Changes for readability and syncing. See [Operations and Changes](/docs/concepts/operations_changes). + +**Parameters:** + +- `interval` - Merge interval in milliseconds + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setChangeMergeInterval(1000); // Merge changes within 1 second +``` + + + + +```typescript no_run +configTextStyle(styles: StyleConfig): void +``` + + +Configures the behavior of text styles (marks) in rich text containers. Marks can expand when edits happen at their edges (before/after/both/none). For a primer on rich text and marks in Loro, see [Text](/docs/tutorial/text). + +**Parameters:** + +- `styles` - Configuration object mapping style names to their config + +**StyleConfig Type:** + +```typescript no_run +type StyleConfig = Record< + string, + { + expand?: "after" | "before" | "both" | "none"; + } +>; +``` + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + italic: { expand: "none" }, + link: { expand: "none" }, +}); +``` + + + + +```typescript no_run +configDefaultTextStyle(style?: { expand: "after" | "before" | "both" | "none" }): void +``` + + +Configures the default text style for the document when using LoroText. If undefined is provided, the default style is reset. + +**Parameters:** + +- `style` - Default style configuration (optional) + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.configDefaultTextStyle({ expand: "after" }); +``` + + + +### Container Access Methods + +**📝 Note:** Creating root containers (e.g., `doc.getText("...")`) does not record operations; nested container creation (e.g., `map.setContainer(...)`) does. + +**⚠️ Pitfall:** Avoid concurrent creation of regular child containers with the same key in LoroMaps. Instead of: + +```ts no_run +// Dangerous - concurrent peers can create different child container IDs +doc.getMap("user").getOrCreateContainer(userId, new LoroMap()); +``` + +Use a mergeable child container when the child should be identified by its logical position: + +```ts no_run +doc.getMap("user").ensureMergeableMap(userId); +``` + +Alternatively, use a unique root container if that better fits your model: + +```ts no_run +doc.getMap("user." + userId); +``` + + +```typescript no_run +getText(name: string): LoroText +``` + + + +Gets or creates a text container with the given name. New to LoroText and marks? See [Text](/docs/tutorial/text). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroText` instance + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("content"); +text.insert(0, "Hello"); +``` + + + + +```typescript no_run +getList(name: string): LoroList +``` + + + +Gets or creates a list container with the given name. Unsure whether to use List or MovableList? See [List and Movable List](/docs/tutorial/list) and the type selection guide [Choosing CRDT Types](/docs/concepts/choose_crdt_type). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroList` instance + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const list = doc.getList("items"); +list.push("Item 1"); +``` + + + + +```typescript no_run +getMap(name: string): LoroMap +``` + + +Gets or creates a map container with the given name. See [Map](/docs/tutorial/map) for basics and patterns. + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroMap` instance + +**Example:** + +```ts twoslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const map = doc.getMap("settings"); +map.set("theme", "dark"); +``` + + + + +```typescript no_run +getTree(name: string): LoroTree +``` + + +Gets or creates a tree container with the given name. Learn about hierarchical editing and moves in [Tree](/docs/tutorial/tree). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroTree` instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const tree = doc.getTree("fileSystem"); +const root = tree.createNode(); +``` + + + + +```typescript no_run +getCounter(name: string): LoroCounter +``` + + +Gets or creates a counter container with the given name. Counters are special CRDTs that sum concurrent increments; see [Counter](/docs/tutorial/counter). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroCounter` instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const counter = doc.getCounter("likes"); +counter.increment(1); +``` + + + + +```typescript no_run +getMovableList(name: string): LoroMovableList +``` + + +Gets or creates a movable list container with the given name. MovableList is designed for reordering with concurrent moves. See [List and Movable List](/docs/tutorial/list) and [Choosing CRDT Types](/docs/concepts/choose_crdt_type). + +**Parameters:** + +- `name` - The container name + +**Returns:** A `LoroMovableList` instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const movableList = doc.getMovableList("tasks"); +movableList.push("Task 1"); +movableList.push("Task 2"); +movableList.push("Task 3"); +movableList.move(0, 2); // Move first item to third position +``` + + + + +```typescript no_run +getContainerById(id: ContainerID): Container | undefined +``` + + +Gets a container by its unique ID. Container IDs (CID) uniquely reference containers across updates; see [Container ID](/docs/advanced/cid) and [Container](/docs/concepts/container). + +**Parameters:** + +- `id` - The container ID + +**Returns:** The container instance or undefined if not found + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +const textId = text.id; +const sameText = doc.getContainerById(textId); +``` + + + +### Import/Export Methods + + +```typescript no_run +export(mode?: ExportMode): Uint8Array +``` + + +Exports the document in various formats for synchronization or persistence. For a walkthrough of export modes—snapshot, update, shallow-snapshot, and updates-in-range—see [Export Mode](/docs/tutorial/encoding). Shallow snapshots remove history while keeping current state; see [Shallow Snapshots](/docs/concepts/shallow_snapshots). VersionVector and Frontiers are two ways to represent versions; see [Version Vector](/docs/concepts/version_vector) and [Frontiers](/docs/concepts/frontiers). + +**Parameters:** + +- `mode` - Export configuration (optional) + +**ExportMode Options:** + +```typescript no_run +type ExportMode = + | { mode: "snapshot" } + | { mode: "update"; from?: VersionVector } + | { mode: "shallow-snapshot"; frontiers: Frontiers } + | { mode: "updates-in-range"; spans: { id: OpId; len: number }[] }; +``` + +**Returns:** Encoded binary data + +**⚠️ Important Notes:** + +- **Shallow snapshots**: Cannot import updates from before the shallow start point. Peers can only sync if they have versions after this point. +- **Auto-commit**: The document automatically commits pending operations before export. +- **Performance**: Export new snapshots periodically to reduce import times for new peers. + +**Examples:** + +```typescript no_run +import { LoroDoc, VersionVector } from "loro-crdt"; + +const doc = new LoroDoc(); +// ... make some changes to the document ... + +// Export full snapshot +const snapshot = doc.export({ mode: "snapshot" }); + +// Export updates from a specific version +const lastSyncVersion = doc.version(); // Get current version +// ... make more changes ... +const updates = doc.export({ + mode: "update", + from: lastSyncVersion, +}); + +// Export shallow snapshot at current version +const shallowSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), +}); +``` + + + + +```typescript no_run +import(data: Uint8Array): ImportStatus +``` + + +Imports updates or snapshots into the document. Returns an `ImportStatus` describing which peer ranges were applied or are pending. See [Sync](/docs/tutorial/sync) and [Import Status](/docs/concepts/import_status) for how Loro handles out-of-order and partial updates. + +**Parameters:** + +- `data` - Binary data or another LoroDoc to import from + +**⚠️ Important:** LoroDoc will automatically commits pending operations before import. If the doc is in detached mode, the imported operations are recorded into OpLog but not applied to DocState until you call `attach()`, see [Attached vs Detached States](/docs/concepts/attached_detached) adn [OpLog and DocState](/docs/concepts/oplog_docstate). + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Receive updates from another peer (e.g., via network) +const otherDoc = new LoroDoc(); +otherDoc.getText("text").insert(0, "Hello"); +const updates: Uint8Array = otherDoc.export({ mode: "update" }); + +// Import binary updates +const status = doc.import(updates); +console.log(status.success); +``` + + + + +```typescript no_run +importBatch(data: Uint8Array[]): ImportStatus +``` + + +Efficiently imports multiple updates in a single batch operation. See [Batch Import](/docs/advanced/import_batch) for performance considerations and usage. + +**Parameters:** + +- `data` - Array of binary updates + +**Example:** + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +declare const update1: Uint8Array; +declare const update2: Uint8Array; +declare const update3: Uint8Array; + +// Usage example: +const updates = [update1, update2, update3]; +const status = doc.importBatch(updates); +``` + + + + +```typescript no_run +exportJsonUpdates(start?: VersionVector, end?: VersionVector, withPeerCompression?: boolean): JsonSchema +``` + + +Exports updates in JSON format for debugging or alternative storage. See [Export Mode](/docs/tutorial/encoding) for format details and trade-offs. + +**Parameters:** + +- `start` - Starting version (optional) +- `end` - Ending version (optional) + +**Returns:** JSON representation of updates + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const jsonUpdates = doc.exportJsonUpdates(); +console.log(JSON.stringify(jsonUpdates, null, 2)); +``` + + + + +```typescript no_run +importJsonUpdates(json: string | JsonSchema): void +``` + + +Imports updates from JSON format. Useful for debugging, migration, or custom storage layers; see [Export Mode](/docs/tutorial/encoding). + +**Parameters:** + +- `json` - JSON string or object containing updates + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const otherDoc = new LoroDoc(); +otherDoc.getText("text").insert(0, "Hello"); +const jsonStr = otherDoc.exportJsonUpdates(); +doc.importJsonUpdates(jsonStr); +``` + + + +## Versioning + +Work with the history DAG using frontiers (heads) and version vectors. Switch, branch, and merge versions safely without manual conflict resolution. See [Versioning Deep Dive](/docs/advanced/version_deep_dive) and [Attached vs Detached States](/docs/concepts/attached_detached). + +### Version Control Methods + + +```typescript no_run +checkout(frontiers: Frontiers): void +``` + + +Checks out the document to a specific version, making it read-only at that point in history. This is the core of time travel; see [Time Travel](/docs/tutorial/time_travel) and [Version](/docs/tutorial/version). + +**Parameters:** + +- `frontiers` - Array of OpIds representing the target version + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +// Make some changes... +doc.checkout(frontiers); // Go back to previous version +``` + + + + +**⚠️ Important:** In Loro 1.0, `version()`/`frontiers()` include pending (uncommitted) local operations. + +**📝 Note:** After `checkout()`, the document enters "detached" mode and becomes read-only by default. Use `attach()` or `checkoutToLatest()` to return to editing mode. See [Version Deep Dive](/docs/advanced/version_deep_dive) and [Attached vs Detached States](/docs/concepts/attached_detached). + + + + +```typescript no_run +checkoutToLatest(): void +``` + + +Returns the document to the latest version after a checkout. Related concepts: [Frontiers](/docs/concepts/frontiers) and [Version Vector](/docs/concepts/version_vector). + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.checkoutToLatest(); +``` + + + + +```typescript no_run +attach(): void +``` + + +Attaches the document to track latest changes after being detached. See [Attached vs Detached States](/docs/concepts/attached_detached) for how Loro separates current state from history. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.attach(); +``` + + + + +```typescript no_run +detach(): void +``` + + +Detaches the document from tracking latest changes, freezing it at current version. See [Attached vs Detached States](/docs/concepts/attached_detached). + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.detach(); +``` + + + + +```typescript no_run +fork(): LoroDoc +``` + + +Creates a new document that is a fork of the current one with a new peer ID. Forking is useful for branching workflows; see [Version](/docs/tutorial/version). + +**Returns:** A new LoroDoc instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const forkedDoc = doc.fork(); +``` + + + + +```typescript no_run +forkAt(frontiers: Frontiers): LoroDoc +``` + + +Creates a fork at a specific version in history. Learn more about versions, DAG history, and heads in [Version Deep Dive](/docs/advanced/version_deep_dive). + +**Parameters:** + +- `frontiers` - The version to fork from + +**Returns:** A new LoroDoc instance + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const forkedDoc = doc.forkAt(frontiers); +``` + + + +## Events & Transactions + +React to changes and group local operations into transactions. Starting in v1.8, events are delivered synchronously; older releases require awaiting a microtask. See [Event Handling](/docs/tutorial/event) and [Transaction Model](/docs/concepts/transaction_model). + +### Subscription Methods + + +```typescript no_run +subscribe(listener: (event: LoroEventBatch) => void): () => void +``` + + +Subscribes to all document changes. See [Event Handling](/docs/tutorial/event) for the event model and best practices. + +**Parameters:** + +- `listener` - Callback function that receives change events + +**Returns:** Unsubscribe function + +**Event Structure:** + +```typescript no_run +interface LoroEventBatch { + by: "local" | "import" | "checkout"; + origin?: string; + currentTarget?: ContainerID; + events: LoroEvent[]; + from: Frontiers; + to: Frontiers; +} +``` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const unsubscribe = doc.subscribe((event) => { + console.log("Change type:", event.by); + event.events.forEach((e) => { + console.log("Container changed:", e.target); + console.log("Diff:", e.diff); + }); +}); + +// Later: unsubscribe(); +``` + +**⚠️ Important:** Events are emitted synchronously as of v1.8. If you are pinned to `<=1.7.x`, await a microtask before reading the batch. + +```ts no_run +doc.commit(); +// Events have already been delivered in v1.8+ +// await Promise.resolve(); // Only needed on <=1.7.x +``` + +**📝 Note:** Multiple operations before a commit are batched into a single event. See [Event Handling](/docs/tutorial/event). + + + + +```typescript no_run +subscribeLocalUpdates(f: (bytes: Uint8Array) => void): () => void +``` + + +Subscribes only to local changes, useful for syncing with remote peers. This is typically wired to your transport layer; see [Sync](/docs/tutorial/sync). + +**Parameters:** + +- `f` - Callback that receives binary updates + +**Returns:** Unsubscribe function + +**Example:** + +```ts no_run threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +declare const websocket: { send: (data: Uint8Array) => void }; + +// Usage example: +const unsubscribe = doc.subscribeLocalUpdates((updates) => { + // Send updates to remote peers + websocket.send(updates); +}); +``` + + + + +```typescript no_run +subscribeFirstCommitFromPeer(f: (e: { peer: PeerID }) => void): () => void +``` + + +Subscribes to the first commit from each peer, useful for tracking peer metadata. + +**Parameters:** + +- `f` - Callback that receives peer information + +**Returns:** Unsubscribe function + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.subscribeFirstCommitFromPeer(({ peer }) => { + // Store peer metadata + doc.getMap("peers").set(peer, { + joinedAt: Date.now(), + name: `User ${peer}`, + }); +}); +``` + + + +### Transaction Methods + + +```typescript no_run +commit(options?: { origin?: string, message?: string, timestamp?: number }): void +``` + + +Commits pending changes as a single transaction. A transaction groups operations into a Change; see [Operations and Changes](/docs/concepts/operations_changes). + +**⚠️ Critical Distinction:** Loro transactions are NOT ACID database transactions: + +- No rollback capability +- No isolation guarantees +- Purpose: Bundle local operations for event batching and history grouping +- Many operations (import/export/checkout) trigger implicit commits + +See [Transaction Model](/docs/concepts/transaction_model). + +**Parameters:** + +- `options` - Optional commit configuration + - `message` - Commit message (persisted in the document like a git commit message, visible to all peers after sync) + - `origin` - Origin identifier (local only - used for marking local events, remote peers won't see this) + - `timestamp` - Unix timestamp in seconds (see [Storing Timestamps](/docs/advanced/timestamp)) + +**Important distinction:** + +- `message` is persisted in the document's history and will be synchronized to all peers, similar to git commit messages +- `origin` is only used locally for filtering events (e.g., excluding certain origins from undo) and is NOT synchronized to remote peers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.commit({ + message: "Updated document title", // Persisted & synced to all peers + origin: "user-action", // Local only, for event filtering + timestamp: Math.floor(Date.now() / 1000), +}); +``` + + + +### Query Methods + + +```typescript no_run +toJSON(): Value +``` + + +Converts the entire document to a JSON-compatible value. If you prefer a structure where sub-containers are referenced by ID (for privacy or streaming), use getShallowValue(); see [Shallow Snapshots](/docs/concepts/shallow_snapshots). + +**Returns:** JSON representation of the document + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const json = doc.toJSON(); +console.log(JSON.stringify(json, null, 2)); +``` + + + + +```typescript no_run +getShallowValue(): Record +``` + + +Gets a shallow representation where sub-containers are represented by their IDs. This is helpful when you want to share structure without history; see [Shallow Snapshots](/docs/concepts/shallow_snapshots). + +**Returns:** Shallow JSON value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const shallow = doc.getShallowValue(); +// Sub-containers appear as: "cid:..." +``` + + + + +```typescript no_run +getDeepValueWithID(): any +``` + + +Gets the deep value of the document with container IDs preserved. This is useful when you need to traverse the document structure while maintaining references to container IDs. + +**Returns:** Document value with container IDs + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const deepValue = doc.getDeepValueWithID(); +``` + + + + +```typescript no_run +version(): VersionVector +``` + + +Gets the current version vector of the document. Version vectors track how much data from each peer you’ve seen; see [Version Vector](/docs/concepts/version_vector). + +**Returns:** Map from PeerID to counter + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.version(); +console.log(vv.toJSON()); +``` + + + + +```typescript no_run +frontiers(): Frontiers +``` + + +Gets the current frontiers (heads) of the document. Frontiers are a compact representation of a version; see [Frontiers](/docs/concepts/frontiers) for when to use them instead of version vectors. + +**📝 Note:** Frontiers are a compact version representation. + +**⚠️ Limitation:** When you have a Frontier pointing to operations you don't know about, you cannot determine the complete set of operation IDs included in that version. Version Vectors don't have this limitation but are more verbose. See [Frontiers](/docs/concepts/frontiers) for trade-offs. + +**Returns:** Array of OpIds + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +// Can be used for checkouts or shallow snapshots +``` + + + + +```typescript no_run +diff(from: Frontiers, to: Frontiers, for_json?: boolean): [ContainerID, Diff | JsonDiff][] +``` + + +Calculates differences between two versions. Understanding how Loro computes diffs benefits from the history DAG model; see [Version Deep Dive](/docs/advanced/version_deep_dive). + +**Parameters:** + +- `from` - Starting frontiers +- `to` - Ending frontiers +- `for_json` - If true, returns JsonDiff format (default: true) + +**Returns:** Array of container IDs and their diffs + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const fromFrontiers = doc.frontiers(); +// Make changes... +const toFrontiers = doc.frontiers(); + +const diffs = doc.diff(fromFrontiers, toFrontiers); +diffs.forEach(([containerId, diff]) => { + console.log(`Container ${containerId} changed:`, diff); +}); +``` + + + +### Pre-Commit Hook + + +```typescript no_run +subscribePreCommit(f: (e: { changeMeta: Change, origin: string, modifier: ChangeModifier }) => void): () => void +``` + + +Subscribe to the pre-commit event. You can modify the message and timestamp of the next change. This hook runs right before a Change is recorded; see [Transaction Model](/docs/concepts/transaction_model) and [Operations and Changes](/docs/concepts/operations_changes). + +Pitfall: `commit()` can be triggered implicitly by `import`, `export`, and `checkout`. Use this hook to attach metadata even for those implicit commits. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const unsubscribe = doc.subscribePreCommit(({ modifier }) => { + modifier + .setMessage("Tagged by pre-commit") + .setTimestamp(Math.floor(Date.now() / 1000)); +}); +doc.getText("text").insert(0, "Hello"); +doc.commit(); +unsubscribe(); +``` + + + +### Cursor Utilities + + +```typescript no_run +getCursorPos(cursor: Cursor): { update?: Cursor, offset: number, side: Side } +``` + + +Resolve a stable `Cursor` to an absolute position. Cursors remain valid across concurrent edits; see [Cursor and Stable Positions](/docs/concepts/cursor_stable_positions) and [Cursor tutorial](/docs/tutorial/cursor). The side controls affinity when the cursor sits at an insertion boundary. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "abc"); + +// Get cursor at position 1 +const c0 = text.getCursor(1); +const pos = doc.getCursorPos(c0!); +console.log(pos.offset); // 1 +``` + + + +### Pending Operations + + +```typescript no_run +getUncommittedOpsAsJson(): JsonSchema | undefined +``` + + +Get pending operations from the current transaction in JSON format. Useful for debugging what will be included in the next Change; see [Transaction Model](/docs/concepts/transaction_model). + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Hello"); +const pending = doc.getUncommittedOpsAsJson(); +doc.commit(); +const none = doc.getUncommittedOpsAsJson(); // undefined after commit +``` + + + +### Change Graph & History + +These APIs traverse the history DAG of changes (ancestors/descendants, spans). If this sounds unfamiliar, start with Loro's [Versioning Deep Dive](/docs/advanced/version_deep_dive) and the [Event Graph Walker](/docs/concepts/event_graph_walker). + + +```typescript no_run +travelChangeAncestors(ids: OpId[], f: (change: Change) => boolean): void +``` + + +Visit ancestors of the given changes in causal order. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello"); +doc.commit(); +const head = doc.frontiers(); +doc.travelChangeAncestors(head, (change) => { + console.log(change.peer, change.counter); + return true; // continue +}); +``` + + + + + ```typescript no_run findIdSpansBetween(from: Frontiers, to: Frontiers): + VersionVectorDiff ``` + +Find the op id spans that lie between two versions. + + +```typescript no_run +exportJsonInIdSpan(idSpan: { peer: PeerID, counter: number, length: number }): JsonChange[] +``` + + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const a = new LoroDoc(); +const b = new LoroDoc(); + +// Usage example: +a.getText("text").update("Hello"); +a.commit(); +const snapshot = a.export({ mode: "snapshot" }); +let printed: any; +b.subscribe((e) => { +const spans = b.findIdSpansBetween(e.from, e.to); +const changes = b.exportJsonInIdSpan(spans.forward[0]); +printed = changes; +}); +b.import(snapshot); + +```` + + + +```typescript no_run +getChangedContainersIn(id: OpId, len: number): ContainerID[] +```` + + + +Get container IDs modified in the given ID range. + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getList("list").insert(0, 1); +doc.commit(); +const head = doc.frontiers()[0]; +const containers = doc.getChangedContainersIn(head, 1); +``` + + + +### Revert & Apply Diff + + +```typescript no_run +revertTo(frontiers: Frontiers): void +``` + + +Revert the document to a given version by generating inverse operations. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setPeerId("1"); +const t = doc.getText("text"); +t.update("Hello"); +doc.commit(); +doc.revertTo([{ peer: "1", counter: 1 }]); +``` + + + + +```typescript no_run +applyDiff(diff: [ContainerID, Diff | JsonDiff][]): void +``` + + +Apply a batch of diffs to the document. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); + +// Usage example: +doc1.getText("text").insert(0, "Hello"); +const diff = doc1.diff([], doc1.frontiers()); +doc2.applyDiff(diff); +``` + +**Workflow example (squash-like diffs):** For PR-style reviews, combine `diff` and `applyDiff` to send a compact change set between a base version and a new version. Operations that cancel out (insert + delete) are compressed away. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const baseDoc = new LoroDoc(); +const baseText = baseDoc.getText("text"); +baseText.insert(0, "hello world"); + +// Fork to make isolated edits +const newDoc = baseDoc.fork(); +const newText = newDoc.getText("text"); +newText.insert(0, "abc"); +newText.delete(0, 4); + +const diff = newDoc.diff(baseDoc.frontiers(), newDoc.frontiers()); +console.log(diff); +// [ +// [ +// "cid:root-text:Text", +// { type: "text", diff: [ { delete: 1 } ] } +// ] +// ] + +baseDoc.applyDiff(diff); +console.log(baseDoc.toJSON()); +// { text: "ello world" } +``` + + + +### Detached Editing + + +```typescript no_run +setDetachedEditing(enable: boolean): void +``` + + +Enables or disables detached editing mode. Detached editing lets you stage edits separate from the latest head; see [Attached vs Detached States](/docs/concepts/attached_detached). + +**Parameters:** + +- `enable` - Whether to enable detached editing + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setDetachedEditing(true); +``` + + + + +```typescript no_run +isDetachedEditingEnabled(): boolean +``` + + +Checks if detached editing mode is enabled. + +**Returns:** True if detached editing is enabled + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const enabled = doc.isDetachedEditingEnabled(); +``` + + + + +```typescript no_run +isDetached(): boolean +``` + + +Checks if the document is currently detached. + +**Returns:** True if document is detached + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +console.log(doc.isDetached()); +``` + + + +### Commit Options Helpers + + +```typescript no_run +setNextCommitMessage(msg: string): void +``` + + +Sets the message for the next commit. + +**Parameters:** + +- `msg` - Commit message + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitMessage("User action"); +``` + + + + +```typescript no_run +setNextCommitOrigin(origin: string): void +``` + + +Sets the origin for the next commit. + +**Parameters:** + +- `origin` - Origin identifier + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitOrigin("ui"); +``` + + + + +```typescript no_run +setNextCommitTimestamp(timestamp: number): void +``` + + +Sets the timestamp for the next commit. + +**Parameters:** + +- `timestamp` - Unix timestamp in seconds + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitTimestamp(Math.floor(Date.now() / 1000)); +``` + + + + +```typescript no_run +setNextCommitOptions(options: { origin?: string, timestamp?: number, message?: string }): void +``` + + +Sets multiple options for the next commit. + +**Parameters:** + +- `options` - Commit options object + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setNextCommitOptions({ origin: "ui", message: "batch" }); +doc.getText("text").insert(0, "Hi"); +doc.commit(); +``` + + + + +```typescript no_run +clearNextCommitOptions(): void +``` + + +Clears all pending commit options. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.clearNextCommitOptions(); +``` + + + +### Version & Frontier Utilities + + +```typescript no_run +frontiersToVV(frontiers: Frontiers): VersionVector +``` + + +Converts frontiers to a version vector. + +**Parameters:** + +- `frontiers` - Frontiers to convert + +**Returns:** Version vector representation + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const vv = doc.frontiersToVV(frontiers); +``` + + + + +```typescript no_run +vvToFrontiers(vv: VersionVector): Frontiers +``` + + +Converts a version vector to frontiers. + +**Parameters:** + +- `vv` - Version vector to convert + +**Returns:** Frontiers representation + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.version(); +const frontiers = doc.vvToFrontiers(vv); +``` + + + + +```typescript no_run +oplogVersion(): VersionVector +``` + + +Gets the oplog version vector. + +**Returns:** Oplog version vector + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.oplogVersion(); +``` + + + + +```typescript no_run +oplogFrontiers(): Frontiers +``` + + +Gets the oplog frontiers. + +**Returns:** Oplog frontiers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.oplogFrontiers(); +``` + + + + +```typescript no_run +cmpWithFrontiers(frontiers: Frontiers): -1 | 0 | 1 +``` + + +Compares current document state with given frontiers. + +**Parameters:** + +- `frontiers` - Frontiers to compare with + +**Returns:** -1 if behind, 0 if equal, 1 if ahead + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const cmp = doc.cmpWithFrontiers(frontiers); +``` + + + + +```typescript no_run +cmpFrontiers(a: Frontiers, b: Frontiers): -1 | 0 | 1 | undefined +``` + + +Compares two frontiers. + +**Parameters:** + +- `a` - First frontiers +- `b` - Second frontiers + +**Returns:** -1 if a < b, 0 if equal, 1 if a > b, undefined if incomparable + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const f1 = doc.frontiers(); +const f2 = doc.frontiers(); +const cmp = doc.cmpFrontiers(f1, f2); +``` + + + +### JSONPath & Path Queries + +Use simple path strings and JSONPath to fetch nested values and containers. Paths are formed from root container names and keys (e.g., map/key or list/0). For container IDs, see [Container ID](/docs/advanced/cid). + + +```typescript no_run +getByPath(path: string): Value | Container | undefined +``` + + +Gets a value or container by its path. + +**Parameters:** + +- `path` - Path string (e.g., "map/key") + +**Returns:** Value or container at the path, or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("key", 1); + +// Usage example: +const value = doc.getByPath("map/key"); +``` + + + + +```typescript no_run +getPathToContainer(id: ContainerID): (string | number)[] | undefined +``` + + +Gets the path to a container by its ID. + +**Parameters:** + +- `id` - Container ID + +**Returns:** Array representing the path, or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const path = doc.getPathToContainer(map.id); +``` + + + + +```typescript no_run +JSONPath(jsonpath: string): any[] +``` + + +Queries the document using JSONPath syntax. + +**Parameters:** + +- `jsonpath` - JSONPath query string + +**Returns:** Array of matching values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("key", 1); + +// Usage example: +const results = doc.JSONPath("$.map"); +``` + + + +### Shallow Doc Utilities + +These helpers relate to shallow snapshots and redaction. If you need a refresher on what “shallow” means, see [Shallow Snapshots](/docs/concepts/shallow_snapshots). + + +```typescript no_run +shallowSinceVV(): VersionVector +``` + + +Gets the version vector since which the document is shallow. + +**Returns:** Version vector + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const vv = doc.shallowSinceVV(); +``` + + + + +```typescript no_run +shallowSinceFrontiers(): Frontiers +``` + + +Gets the frontiers since which the document is shallow. + +**Returns:** Frontiers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.shallowSinceFrontiers(); +``` + + + + +```typescript no_run +isShallow(): boolean +``` + + +Checks if the document is shallow. + +**Returns:** True if document is shallow + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const shallow = doc.isShallow(); +``` + + + + +```typescript no_run +setHideEmptyRootContainers(hide: boolean): void +``` + + +Controls whether empty root containers are hidden in JSON output. + +**Parameters:** + +- `hide` - Whether to hide empty root containers + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.setHideEmptyRootContainers(true); +// Now empty roots are hidden in toJSON() +``` + + + + +```typescript no_run +deleteRootContainer(cid: ContainerID): void +``` + + +Deletes a root container. + +**Parameters:** + +- `cid` - Container ID to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Usage example: +doc.deleteRootContainer(map.id); +``` + + + + +```typescript no_run +hasContainer(id: ContainerID): boolean +``` + + +Checks if a container exists in the document. + +**Parameters:** + +- `id` - Container ID to check + +**Returns:** True if container exists + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const exists = doc.hasContainer(map.id); +``` + + + +### JSON Serialization with Replacer + + +```typescript no_run +toJsonWithReplacer(replacer: (key: string | number, value: Value | Container) => Value | Container | undefined): Value +``` + + +Customize JSON serialization of containers and values. + +**Parameters:** + +- `replacer` - Function to transform values during serialization + +**Returns:** Customized JSON value + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const json = doc.toJsonWithReplacer((key, value) => { + if (value instanceof LoroText) { + return value.toDelta(); + } + return value; +}); +``` + + + +### Stats & Introspection + + +```typescript no_run +debugHistory(): void +``` + + +Prints debug information about the document history. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.debugHistory(); +``` + + + + +```typescript no_run +changeCount(): number +``` + + +Gets the total number of changes in the document. + +**Returns:** Number of changes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const changes = doc.changeCount(); +``` + + + + +```typescript no_run +opCount(): number +``` + + +Gets the total number of operations in the document. + +**Returns:** Number of operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const ops = doc.opCount(); +``` + + + + +```typescript no_run +getAllChanges(): Map +``` + + +Gets all changes grouped by peer ID. + +**Returns:** Map of peer ID to changes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const changes = doc.getAllChanges(); +``` + + + + +```typescript no_run +getChangeAt(id: OpId): Change +``` + + +Gets a specific change by operation ID. + +**Parameters:** + +- `id` - Operation ID + +**Returns:** Change object + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "hello"); +doc.commit(); +const changes = doc.getAllChanges(); +const change = changes.get(doc.peerIdStr)?.[0]; +``` + + + + +```typescript no_run +getChangeAtLamport(peer_id: string, lamport: number): Change | undefined +``` + + +Gets a change by peer ID and Lamport timestamp. + +**Parameters:** + +- `peer_id` - Peer ID +- `lamport` - Lamport timestamp + +**Returns:** Change object or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "hello"); +const change = doc.getChangeAtLamport(doc.peerIdStr, 1); +``` + + + + +```typescript no_run +getOpsInChange(id: OpId): any[] +``` + + +Gets all operations in a specific change. + +**Parameters:** + +- `id` - Operation ID + +**Returns:** Array of operations + +**Example:** + +```typescript no_run +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "hello"); +const changes = doc.getAllChanges(); +const ops = doc.getOpsInChange(changes[0].id); +``` + + + + +```typescript no_run +getPendingTxnLength(): number +``` + + +Gets the number of pending operations in the current transaction. + +**Returns:** Number of pending operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +doc.getText("text").insert(0, "x"); +console.log(doc.getPendingTxnLength()); +doc.commit(); +``` + + + +### Import/Export Utilities + + +```typescript no_run +decodeImportBlobMeta(blob: Uint8Array, check_checksum: boolean): ImportBlobMetadata +``` + + +Decodes metadata from an import blob. + +**Parameters:** + +- `blob` - Binary data to decode +- `check_checksum` - Whether to verify checksum + +**Returns:** Import blob metadata + +**Example:** + +```ts threeslash +import { LoroDoc, decodeImportBlobMeta } from "loro-crdt"; + +const doc = new LoroDoc(); +const updates = doc.export({ mode: "update" }); +const meta = decodeImportBlobMeta(updates, true); +``` + + + + +```typescript no_run +redactJsonUpdates(json: string | JsonSchema, version_range: any): JsonSchema +``` + + +Redacts JSON updates within a specified version range. + +Use this to safely remove accidentally leaked sensitive content from history while preserving structure. See [Tips: Redaction](/docs/tutorial/tips). + +**Parameters:** + +- `json` - JSON updates to redact +- `version_range` - Version range for redaction + +**Returns:** Redacted JSON schema + +**Example:** + +```ts threeslash +import { LoroDoc, redactJsonUpdates } from "loro-crdt"; + +const doc = new LoroDoc(); +const json = doc.exportJsonUpdates(); +const redacted = redactJsonUpdates(json, { [doc.peerIdStr]: [0, 999999] }); +``` + + + +--- + +## Container Types + +Common CRDT containers for modeling JSON-like structures. See [Choosing CRDT Types](/docs/concepts/choose_crdt_type) and [Composing CRDTs](/docs/tutorial/composition) for when to use each and how to nest them. + +### LoroText + +A rich text container supporting collaborative text editing with formatting. Supports overlapping marks (bold, italic, links) and +stable cursors. The merge semantics avoid interleaving artifacts under concurrency (Fugue + Eg-walker ideas); you use simple index +APIs and Loro handles index transformation. +See [Text](/docs/tutorial/text), [Eg-walker](/docs/advanced/event_graph_walker), and the rich text blog: https://loro.dev/blog/loro-richtext + +**⚠️ Critical: UTF-16 String Encoding** + +LoroText uses **UTF-16** encoding, matching JavaScript's native string encoding: + +- All standard methods (`insert()`, `delete()`, `mark()`, `slice()`, `charAt()`) use UTF-16 code unit indices +- `length` returns UTF-16 code units (same as JavaScript `string.length`) +- Use `insertUtf8()` and `deleteUtf8()` for UTF-8 byte-based operations when integrating with UTF-8 systems + +**⚠️ Common Pitfalls:** + +1. **Index Misalignment**: UTF-16 indices differ from visual character count +2. **Performance**: Cursor queries on deleted positions require history traversal - in that case, it will return a refreshed Cursor object that does not point to the deleted text + +**Example with emoji:** + +```typescript no_run +const text = doc.getText("text"); +text.insert(0, "Hello 😀 World"); +console.log(text.length); // 13 (emoji counts as 2) +console.log(text.toString()[6]); // ⚠️ Invalid - splits the emoji +text.delete(6, 2); // ✅ Correct - deletes entire emoji +text.delete(6, 1); // ❌ Wrong - corrupts the emoji + +// Safe iteration +text.iter((char) => { + console.log(char); // Each character handled correctly + return true; +}); +``` + +**📝 Text vs String in Maps:** + +- Use `LoroText` for collaborative text editing where all concurrent edits must be preserved +- Use regular strings in `LoroMap` for atomic values (URLs, IDs, hashes) where Last-Write-Wins is preferred +- Example: URLs should be strings in maps, not LoroText. Otherwise, the automatically merged result may be an invalid URL + + + ```typescript no_run insert(index: number, text: string): void ``` + + + +Inserts text at the specified position using UTF-16 code unit indices (same as JavaScript string indices). + +**Parameters:** + +- `index` - UTF-16 code unit position to insert at (0-based, same as JavaScript string index) +- `text` - Text to insert + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// Usage example: +text.insert(0, "Hello "); +text.insert(6, "World"); +``` + + + + + ```typescript no_run delete(index: number, len: number): void ``` + + + +Deletes text from the specified position using UTF-16 code units. + +**Parameters:** + +- `index` - Starting UTF-16 code unit position (same as JavaScript string index) +- `len` - Number of UTF-16 code units to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello 😀 World"); +text.delete(6, 2); // Delete emoji (2 UTF-16 units) +text.delete(5, 1); // Delete space before World +``` + + + + +```typescript no_run +mark(range: { start: number, end: number }, key: string, value: Value): void +``` + + + +Applies formatting to a text range. Marks can be configured to expand/stop at edges via configTextStyle(); see [Text](/docs/tutorial/text) for mark behavior. + +**Parameters:** + +- `range` - The range to format +- `key` - Style attribute name +- `value` - Style value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +doc.configTextStyle({ bold: { expand: "after" } }); +text.mark({ start: 0, end: 5 }, "bold", true); +``` + + + + +```typescript no_run +unmark(range: { start: number, end: number }, key: string): void +``` + + +Removes formatting from a text range. For how conflicting edits on marks resolve, see [Text](/docs/tutorial/text). + +**Parameters:** + +- `range` - The range to unformat +- `key` - Style attribute to remove + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +text.mark({ start: 0, end: 5 }, "bold", true); +text.unmark({ start: 0, end: 5 }, "bold"); +``` + + + + +```typescript no_run +toDelta(): Delta[] +``` + + +Converts text to Delta format (Quill-compatible). + +**Returns:** Array of Delta operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +text.mark({ start: 0, end: 5 }, "bold", true); +const delta = text.toDelta(); +// [{ insert: "Hello", attributes: { bold: true } }, { insert: " World" }] +``` + + + + +```typescript no_run +sliceDelta(start: number, end: number): Delta[] +``` + + +Returns a Quill-style Delta for a subsection of the text, using UTF-16 indices. Useful for copying a styled span. Use `sliceDeltaUtf8` if you need UTF-8 byte offsets instead. + +**Parameters:** + +- `start` - Start UTF-16 code unit index (inclusive) +- `end` - End UTF-16 code unit index (exclusive) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + comment: { expand: "none" }, +}); +const text = doc.getText("text"); + +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +text.mark({ start: 6, end: 11 }, "comment", "greeting"); + +const snippet = text.sliceDelta(1, 8); +expect(snippet).toStrictEqual([ + { insert: "ello", attributes: { bold: true } }, + { insert: " " }, + { insert: "Wo", attributes: { comment: "greeting" } }, +]); +``` + + + + +```typescript no_run +sliceDeltaUtf8(start: number, end: number): Delta[] +``` + + +Returns a Quill-style Delta for a subsection of the text using **UTF-8 byte offsets**. Choose this when your offsets come from UTF-8 encoded buffers. + +**Parameters:** + +- `start` - Start byte offset (inclusive) +- `end` - End byte offset (exclusive) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hi 👋"); + +const enc = new TextEncoder(); +const start = enc.encode("Hi ").length; // 3 bytes +const end = enc.encode("Hi 👋").length; // 7 bytes + +const delta = text.sliceDeltaUtf8(start, end); +expect(delta).toStrictEqual([{ insert: "👋" }]); +``` + + + + +```typescript no_run +applyDelta(delta: Delta[]): void +``` + + +Applies Delta operations to the text. + +**Parameters:** + +- `delta` - Array of Delta operations + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.applyDelta([ + { insert: "Hello", attributes: { bold: true } }, + { insert: " World" }, +]); +``` + + + + +```typescript no_run +update(text: string, options?: { timeoutMs?: number; useRefinedDiff?: boolean }): void +``` + + +Updates the current text to the target text using Myers' diff algorithm. + +**Parameters:** + +- `text` - New text content +- `options` - Update options + - `timeoutMs` - Optional timeout for the diff computation + - `useRefinedDiff` - Use refined diff for better quality on long texts + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Hello"); +text.update("Hello World", { timeoutMs: 100 }); +``` + + + + +```typescript no_run +updateByLine(text: string, options?: { timeoutMs?: number; useRefinedDiff?: boolean }): void +``` + + +Line-based update that's faster for large texts (less precise than `update`). + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insert(0, "Line A\nLine C"); +text.updateByLine("Line A\nLine B\nLine C"); +``` + + + + +```typescript no_run +getCursor(pos: number, side?: Side): Cursor | undefined +``` + + +Gets a stable cursor position that survives edits. + +**Parameters:** + +- `pos` - Position in the text +- `side` - Cursor affinity (-1, 0, or 1) + +**Returns:** Cursor object or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +const cursor = text.getCursor(5); +// Cursor remains valid even after edits +``` + + + + +```typescript no_run +toString(): string +``` + + +Converts to plain text string. + +**Returns:** Plain text content + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); +const plainText = text.toString(); +``` + + + + +```typescript no_run +charAt(pos: number): string +``` + + +Gets the character at a specific UTF-16 code unit position. + +**Parameters:** + +- `pos` - UTF-16 code unit position + +**Returns:** Character at position + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); +const char = text.charAt(1); // "e" +``` + + + + +```typescript no_run +slice(start: number, end: number): string +``` + + +Extracts a section of the text using UTF-16 code unit positions. + +**Parameters:** + +- `start` - Start UTF-16 code unit index +- `end` - End UTF-16 code unit index (exclusive) + +**Returns:** Sliced text + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello 😀 World"); + +const slice1 = text.slice(0, 5); // "Hello" +const slice2 = text.slice(6, 8); // "😀" (emoji spans 6-8) +const slice3 = text.slice(9, 14); // "World" +``` + + + + +```typescript no_run +splice(pos: number, len: number, s: string): string +``` + + +Replaces text at a position with new content. + +**Parameters:** + +- `pos` - Start position +- `len` - Length to delete +- `s` - String to insert + +**Returns:** Deleted text + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Usage example: +const deleted = text.splice(6, 5, "Loro"); // returns "World" +``` + + + + +```typescript no_run +push(s: string): void +``` + + +Appends text to the end of the document. + +**Parameters:** + +- `s` - String to append + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.push("Hello"); +text.push(" World"); +``` + + + + +```typescript no_run +iter(callback: (char: string) => boolean): void +``` + + +Iterates over each character in the text. + +**Parameters:** + +- `callback` - Function called for each character. Return false to stop iteration. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +text.iter((char) => { + console.log(char); + return true; // continue iteration +}); +``` + + + + +```typescript no_run +insertUtf8(index: number, content: string): void +``` + + +Inserts text at a UTF-8 byte index position. + +**Parameters:** + +- `index` - UTF-8 byte index +- `content` - Text to insert + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +text.insertUtf8(0, "Hello"); +``` + + + + +```typescript no_run +deleteUtf8(index: number, len: number): void +``` + + +Deletes text at a UTF-8 byte index position. + +**Parameters:** + +- `index` - UTF-8 byte index +- `len` - Number of UTF-8 bytes to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello World"); + +// Usage example: +text.deleteUtf8(6, 5); // Delete "World" +``` + + + + +```typescript no_run +getEditorOf(pos: number): PeerID | undefined +``` + + +Gets the peer ID of who last edited the character at a position. + +**Parameters:** + +- `pos` - Character position + +**Returns:** PeerID of last editor or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const editor = text.getEditorOf(0); +``` + + + + +```typescript no_run +kind(): "Text" +``` + + +Returns the container type. + +**Returns:** "Text" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +const type = text.kind(); // "Text" +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this text is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const text = list.insertContainer(0, doc.getText("nested")); + +// Usage example: +const parent = text.parent(); // Returns the list +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const text = new LoroText(); + +const attached = text.isAttached(); // false until attached to doc +``` + + + + +```typescript no_run +getAttached(): LoroText | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const text = new LoroText(); + +const attached = text.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +const deleted = text.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): string +``` + + +Gets the text content without marks. + +**Returns:** Plain text string + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const value = text.getShallowValue(); // "Hello" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the text to JSON representation. + +**Returns:** JSON value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); + +// Usage example: +const json = text.toJSON(); // "Hello" +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +const containerId = text.id; +``` + + + + +```typescript no_run +readonly length: number +``` + + +Gets the length of the text in UTF-16 code units (same as JavaScript's `string.length`). + +**⚠️ Important:** Emoji and other characters outside the Basic Multilingual Plane count as 2 UTF-16 units. This affects all index-based operations: + +```typescript no_run +text.insert(0, "👨‍👩‍👧‍👦"); // Family emoji +console.log(text.length); // 11 (not 1!) - complex emoji with ZWJ sequences +``` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); +console.log(text.length); // 5 + +text.insert(5, " 😀"); +console.log(text.length); // 8 (space + emoji which counts as 2) +``` + + + +### LoroList + +An ordered list container for collaborative arrays. Uses index-based APIs; under concurrency, Loro transforms indices +by replaying only the necessary portion of history (Eg-walker-inspired). See [List and Movable List](/docs/tutorial/list), +[Choosing CRDT Types](/docs/concepts/choose_crdt_type), and [Eg-walker](/docs/advanced/event_graph_walker). + +**⚠️ Important: List vs Map for Coordinates** + +```typescript no_run +// ❌ WRONG - Don't use List for coordinates +const coord = doc.getList("coord"); +coord.push(10); // x +coord.push(20); // y +// Concurrent updates can create [10, 20a, 20b] instead of [10, 20] + +// ✅ CORRECT - Use Map for coordinates +const coord = doc.getMap("coord"); +coord.set("x", 10); +coord.set("y", 20); +// Concurrent updates properly merge to {x: 10, y: 20} +``` + + +```typescript no_run +insert(pos: number, value: Value | Container): void +``` + + +Inserts a value at the specified position. + +**Parameters:** + +- `pos` - Insert position +- `value` - Value to insert + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.insert(0, "First"); +list.insert(1, { type: "object" }); +``` + + + + +```typescript no_run +insertContainer(pos: number, container: T): T +``` + + +Inserts a new container at the position. + +**Parameters:** + +- `pos` - Insert position +- `container` - Container instance + +**Returns:** The inserted container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const subText = list.insertContainer(0, new LoroText()); +subText.insert(0, "Nested text"); +``` + + + + +```typescript no_run +delete(pos: number, len: number): void +``` + + +Deletes elements from the list. + +**Parameters:** + +- `pos` - Starting position +- `len` - Number of elements to delete + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a"); +list.push("b"); +list.push("c"); +list.push("d"); + +list.delete(1, 2); // Delete 2 elements starting at index 1 +``` + + + + +```typescript no_run +push(value: Value | Container): void +``` + + +Appends a value to the end of the list. + +**Parameters:** + +- `value` - Value to append + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("Last item"); +``` + + + + +```typescript no_run +getIdAt(pos: number): { peer: PeerID, counter: number } | undefined +``` + + +```typescript no_run +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +list.insert(0, 1); +const id0 = list.getIdAt(0); + +```` + + + +```typescript no_run +pushContainer(container: T): T +```` + + + +Appends a container to the end of the list. + +**Parameters:** + +- `container` - Container to append + +**Returns:** The appended container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroMap } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const map = list.pushContainer(new LoroMap()); +map.set("key", "value"); +``` + + + + +```typescript no_run +pop(): Value | Container | undefined +``` + + +Removes and returns the last element. + +**Returns:** The removed element or undefined + +**Example:** + +```ts no_run threeslash +import { LoroList } from "loro-crdt"; +declare const list: LoroList; +// ---cut--- +const lastItem = list.pop(); +``` + + + + +```typescript no_run +get(index: number): Value | Container | undefined +``` + + +Gets the value at the specified index. + +**Parameters:** + +- `index` - Element index + +**Returns:** The value or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const list = doc.getList("items"); +list.push("first", "second"); + +const item = list.get(0); // "first" +``` + + + + +```typescript no_run +getCursor(pos: number, side?: Side): Cursor | undefined +``` + + +Gets a stable cursor for the position. + +**Parameters:** + +- `pos` - Position in the list +- `side` - Cursor affinity + +**Returns:** Cursor object or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a", "b", "c"); + +const cursor = list.getCursor(2); +``` + + + + +```typescript no_run +toArray(): (Value | Container)[] +``` + + +Converts the list to a JavaScript array. + +**Returns:** Array of values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a", "b", "c"); +const array = list.toArray(); +``` + + + + +```typescript no_run +clear(): void +``` + + +Removes all elements from the list. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a", "b", "c"); +list.clear(); +``` + + + + +```typescript no_run +length: number +``` + + +Gets the number of elements in the list. + +**Returns:** List length + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push("a"); +list.push("b"); +list.push("c"); +console.log(`List has ${list.length} items`); +``` + + + + +```typescript no_run +kind(): "List" +``` + + +Returns the container type. + +**Returns:** "List" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +const type = list.kind(); // "List" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the list to JSON representation. + +**Returns:** JSON array + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push(1, 2, 3); + +// Usage example: +const json = list.toJSON(); // [1, 2, 3] +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this list is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +const list = map.setContainer("nested", doc.getList("list")); + +// Usage example: +const parent = list.parent(); // Returns the map +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroList } from "loro-crdt"; +const doc = new LoroDoc(); +const list = new LoroList(); + +const attached = list.isAttached(); // false until attached to doc +``` + + + + +```typescript no_run +getAttached(): LoroList | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroList } from "loro-crdt"; +const doc = new LoroDoc(); +const list = new LoroList(); + +const attached = list.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +const deleted = list.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): Value[] +``` + + +Gets the list values with sub-containers as IDs. + +**Returns:** Array of values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.push(1, 2); + +// Usage example: +const values = list.getShallowValue(); // [1, 2] +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +const containerId = list.id; +``` + + + +### LoroMap + +A key-value map container for collaborative objects. See [Map](/docs/tutorial/map). + + +```typescript no_run +set(key: string, value: Value | Container): void +``` + + +Sets a key-value pair. + +Note: Setting a key to the same value is a no-op (no operation recorded). See [Map basics](/docs/tutorial/map). + +**Parameters:** + +- `key` - The key +- `value` - The value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.set("name", "Alice"); +map.set("age", 30); +``` + + + + +```typescript no_run +setContainer(key: string, container: T): T +``` + + +Sets a container as the value for a key. + +**⚠️ Pitfall:** Concurrent regular child container creation at the same key on a LoroMap from multiple peers causes overwrites (different container IDs cannot auto-merge). If the child should be shared by logical key, use the `ensureMergeable*` methods instead. See [Container initialization](/docs/tutorial/tips). + +**Parameters:** + +- `key` - The key +- `container` - Container instance + +**Returns:** The set container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroList } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const list = map.setContainer("items", new LoroList()); +list.push("item1"); +``` + + + + +```typescript no_run +ensureMergeableText(key: string): LoroText +``` + + +Ensures that `key` points to a mergeable Text child container. Peers that call this method concurrently with the same parent Map and key will address the same logical Text container instead of creating competing child container IDs. + +Use this for lazily-created text fields that should behave like one shared document for all peers. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Text container + +**Example:** + +```ts no_run +const body = doc.getMap("note").ensureMergeableText("body"); +body.insert(0, "Hello"); +``` + + + + +```typescript no_run +ensureMergeableMap(key: string): LoroMap +``` + + +Ensures that `key` points to a mergeable Map child container. Use this when a nested record, profile, settings object, or migration-created child Map may be initialized by multiple peers. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Map container + +**Example:** + +```ts no_run +const record = doc.getMap("records").ensureMergeableMap(recordId); +record.set("updated", true); +``` + + + + +```typescript no_run +ensureMergeableList(key: string): LoroList +``` + + +Ensures that `key` points to a mergeable List child container. Use this for dynamic child lists, such as one list per day, where concurrent first creation should produce one shared List. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable List container + +**Example:** + +```ts no_run +const entries = doc.getMap("days").ensureMergeableList("2026-06-08"); +entries.insert(0, "meeting notes"); +``` + + + + +```typescript no_run +ensureMergeableMovableList(key: string): LoroMovableList +``` + + +Ensures that `key` points to a mergeable MovableList child container. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable MovableList container + + + + +```typescript no_run +ensureMergeableTree(key: string): LoroTree +``` + + +Ensures that `key` points to a mergeable Tree child container. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Tree container + + + + +```typescript no_run +ensureMergeableCounter(key: string): LoroCounter +``` + + +Ensures that `key` points to a mergeable Counter child container. + +**Parameters:** + +- `key` - The Map key for the mergeable child container + +**Returns:** The mergeable Counter container + + + + +```typescript no_run +get(key: string): Value | Container | undefined +``` + + +Gets the value for a key. + +**Parameters:** + +- `key` - The key + +**Returns:** The value or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const name = map.get("name"); +``` + + + + +```typescript no_run +/** + * @deprecated Prefer ensureMergeableText/Map/List/... for lazily-created Map child containers. + */ +getOrCreateContainer(key: string, container: T): T +``` + + +**Deprecated:** Prefer the `ensureMergeable*` methods for Map child containers that may be created lazily or concurrently by multiple peers. + +Gets an existing container or creates a new regular child container. + +**⚠️ Pitfall:** Parallel container creation for the same key across peers creates different child container IDs and can cause overwrites. This method is kept for compatibility and for cases where you explicitly want a regular child container. For shared dynamic children, use `ensureMergeableText`, `ensureMergeableMap`, `ensureMergeableList`, `ensureMergeableMovableList`, `ensureMergeableTree`, or `ensureMergeableCounter`. See [Container initialization](/docs/tutorial/tips). + +**Parameters:** + +- `key` - The key +- `container` - Container to create if not exists + +**Returns:** The container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const text = map.getOrCreateContainer("description", new LoroText()); +``` + + + + +```typescript no_run +delete(key: string): void +``` + + +Removes a key-value pair. + +**Parameters:** + +- `key` - The key to remove + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.delete("obsoleteKey"); +``` + + + + +```typescript no_run +clear(): void +``` + + +Removes all key-value pairs. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.clear(); +``` + + + + +```typescript no_run +keys(): string[] +``` + + +Gets all keys in the map. + +**Returns:** Array of keys + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const allKeys = map.keys(); +``` + + + + +```typescript no_run +values(): (Value | Container)[] +``` + + +Gets all values in the map. + +**Returns:** Array of values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const allValues = map.values(); +``` + + + + +```typescript no_run +entries(): [string, Value | Container][] +``` + + +Gets all key-value pairs. + +**Returns:** Array of entries + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +for (const [key, value] of map.entries()) { + console.log(`${key}: ${value}`); +} +``` + + + + +```typescript no_run +getLastEditor(key: string): PeerID | undefined +``` + + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +map.set("k", 1); +doc.commit(); +const who = map.getLastEditor("k"); +// who = doc.peerIdStr + +```` + + + +```typescript no_run +size: number +```` + + + +Gets the number of key-value pairs. + +**Returns:** Map size + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +console.log(`Map has ${map.size} entries`); +``` + + + + +```typescript no_run +kind(): "Map" +``` + + +Returns the container type. + +**Returns:** "Map" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const type = map.kind(); // "Map" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the map to JSON representation. + +**Returns:** JSON object + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("name", "Alice"); + +// Usage example: +const json = map.toJSON(); // { name: "Alice" } +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this map is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); +const map = list.insertContainer(0, doc.getMap("nested")); + +// Usage example: +const parent = map.parent(); // Returns the list +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroMap } from "loro-crdt"; +const doc = new LoroDoc(); +const map = new LoroMap(); + +const attached = map.isAttached(); // false until attached to doc +``` + + + + +```typescript no_run +getAttached(): LoroMap | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroMap } from "loro-crdt"; +const doc = new LoroDoc(); +const map = new LoroMap(); + +const attached = map.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const deleted = map.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): Record +``` + + +Gets the map values with sub-containers as IDs. + +**Returns:** Object with values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("key", "value"); + +// Usage example: +const values = map.getShallowValue(); // { key: "value" } +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +const containerId = map.id; +``` + + + +### LoroTree + +A hierarchical tree container for nested structures. Supports moving subtrees while handling concurrent edits. See [Tree](/docs/tutorial/tree). + +**⚠️ Important Tree Operation Notes:** + +- **Concurrent moves can create cycles**: Loro detects and prevents these automatically +- **Fractional indexing**: Has interleaving issues but maintains relative ordering +- **Don't disable fractional index** if you need siblings to be sorted. See [Tree](/docs/tutorial/tree). + + +```typescript no_run +createNode(parent?: TreeID, index?: number): LoroTreeNode +``` + + +Creates a new tree node. + +**Parameters:** + +- `parent` - Parent node ID (optional, creates root if omitted) +- `index` - Position among siblings (optional) + +**Returns:** The new node's handler + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const root = tree.createNode(); +const child = root.createNode(0); +``` + + + + +```typescript no_run +move(target: TreeID, parent?: TreeID, index?: number): void +``` + + +Moves a node to a new position. + +**Parameters:** + +- `target` - Node to move +- `parent` - New parent (undefined for root) +- `index` - Position among siblings + +**Example:** + +```typescript no_run threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +declare const nodeId: TreeID; +declare const newParentId: TreeID; + +// Usage example: +tree.move(nodeId, newParentId, 0); +``` + + + + +```typescript no_run +delete(target: TreeID): void +``` + + +Deletes a node and its descendants. + +**Parameters:** + +- `target` - Node to delete + +**Example:** + +```ts threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const nodeId = node.id; + +// Usage example: +tree.delete(nodeId); +``` + + + + +```typescript no_run +getNodeByID(id: TreeID): LoroTreeNode | undefined +``` + + +Gets a node handler by its ID. + +**Parameters:** + +- `id` - Node ID + +**Returns:** Node handler or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const _node = tree.createNode(); +const nodeId = _node.id; + +// Usage example: +const node = tree.getNodeByID(nodeId); +if (node) { + node.data.set("label", "New Label"); +} +``` + + + + +```typescript no_run +nodes(): LoroTreeNode[] +``` + + +Gets all nodes in the tree. + +**Returns:** Array of all nodes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const allNodes = tree.nodes(); +``` + + + + +```typescript no_run +roots(): LoroTreeNode[] +``` + + +Gets all root nodes. + +**Returns:** Array of root nodes + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const rootNodes = tree.roots(); +``` + + + + +```typescript no_run +has(target: TreeID): boolean +``` + + +Checks if a node exists. + +**Parameters:** + +- `target` - Node ID to check + +**Returns:** Boolean indicating existence + +**Example:** + +```typescript no_run threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +declare const nodeId: TreeID; + +// Usage example: +if (tree.has(nodeId)) { + console.log("Node exists"); +} +``` + + + + +```typescript no_run +isNodeDeleted(target: TreeID): boolean +``` + + +Checks if a node has been deleted. + +**Parameters:** + +- `target` - Node ID to check + +**Returns:** Boolean indicating deletion status + +**Example:** + +```typescript no_run threeslash +import { LoroDoc, TreeID } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +declare const nodeId: TreeID; + +// Usage example: +if (tree.isNodeDeleted(nodeId)) { + console.log("Node was deleted"); +} +``` + + + +```typescript no_run +enableFractionalIndex(jitter: number): void +``` + + +Enables fractional indexing for better concurrent move performance. + +**Parameters:** + +- `jitter` - Jitter amount for fractional indices + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +tree.enableFractionalIndex(0.001); +``` + + + + +```typescript no_run +disableFractionalIndex(): void +``` + + +Disables fractional indexing. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +tree.disableFractionalIndex(); +``` + + + + +```typescript no_run +isFractionalIndexEnabled(): boolean +``` + + +Checks if fractional indexing is enabled. + +**Returns:** True if enabled + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const enabled = tree.isFractionalIndexEnabled(); +``` + + + + +```typescript no_run +kind(): "Tree" +``` + + +Returns the container type. + +**Returns:** "Tree" + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const type = tree.kind(); // "Tree" +``` + + + + +```typescript no_run +toJSON(): any +``` + + +Converts the tree to JSON representation. + +**Returns:** JSON tree structure + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const json = tree.toJSON(); +``` + + + + +```typescript no_run +parent(): Container | undefined +``` + + +Gets the parent container if this tree is nested. + +**Returns:** Parent container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); +const tree = map.setContainer("tree", doc.getTree("nested")); + +// Usage example: +const parent = tree.parent(); +``` + + + + +```typescript no_run +isAttached(): boolean +``` + + +Checks if the container is attached to a document. + +**Returns:** True if attached + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTree } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = new LoroTree(); + +const attached = tree.isAttached(); +``` + + + + +```typescript no_run +getAttached(): LoroTree | undefined +``` + + +Gets the attached version of this container. + +**Returns:** Attached container or undefined + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTree } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = new LoroTree(); + +const attached = tree.getAttached(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if the container has been deleted. + +**Returns:** True if deleted + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const deleted = tree.isDeleted(); +``` + + + + +```typescript no_run +getShallowValue(): TreeNodeShallowValue[] +``` + + +Gets the tree values with sub-containers as IDs. + +**Returns:** Array of tree node values + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const values = tree.getShallowValue(); +``` + + + + +```typescript no_run +readonly id: ContainerID +``` + + +Gets the unique container ID. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +const containerId = tree.id; +``` + + + +### LoroTreeNode + +Represents a single node in the tree. + + +```typescript no_run +data: LoroMap +``` + + +A map container for storing node metadata. + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +node.data.set("title", "Node Title"); +node.data.set("expanded", true); +``` + + + + +```typescript no_run +createNode(index?: number): LoroTreeNode +``` + + +Creates a child node. + +**Parameters:** + +- `index` - Position among siblings + +**Returns:** New node's handler + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const childId = node.createNode(0); +``` + + + + +```typescript no_run +move(parent?: LoroTreeNode, index?: number): void +``` + + +Moves this node to a new parent. + +**Parameters:** + +- `parent` - New parent node +- `index` - Position among siblings + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTreeNode } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const parent = tree.createNode(); + +// Usage example: +node.move(parent, 0); +``` + + + + +```typescript no_run +moveAfter(target: LoroTreeNode): void +``` + + +Moves this node after a sibling. + +**Parameters:** + +- `target` - Sibling node + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTreeNode } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const sibling = tree.createNode(); + +// Usage example: +node.moveAfter(sibling); +``` + + + + +```typescript no_run +moveBefore(target: LoroTreeNode): void +``` + + +Moves this node before a sibling. + +**Parameters:** + +- `target` - Sibling node + +**Example:** + +```ts threeslash +import { LoroDoc, LoroTreeNode } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); +const sibling = tree.createNode(); + +// Usage example: +node.moveBefore(sibling); +``` + + + + +```typescript no_run +parent(): LoroTreeNode | undefined +``` + + +Gets the parent node. + +**Returns:** Parent node or undefined + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +const parentNode = node.parent(); +``` + + + + +```typescript no_run +children(): LoroTreeNode[] +``` + + +Gets all child nodes. + +**Returns:** Array of children + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +const childNodes = node.children(); +``` + + + + +```typescript no_run +index(): number | undefined +``` + + +Gets the position among siblings. + +**Returns:** Index or undefined if root + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +const position = node.index(); +``` + + + + +```typescript no_run +fractionalIndex(): string | undefined +``` + + +Returns the node's fractional index used to sort siblings deterministically. +It is a hex string representation of the Fractional Index and is stable for ordering. +Returns `undefined` for the root node. Note: the tree must be attached to the document. + +**Returns:** Hex string or `undefined` for root + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const parent = tree.createNode(); +const a = parent.createNode(0); +const b = parent.createNode(1); + +const aFi = a.fractionalIndex(); +const bFi = b.fractionalIndex(); +// aFi < bFi, because b is inserted after a +``` + + + + +```typescript no_run +creationId(): { peer: PeerID, counter: number } +``` + + +Returns the OpID that created this node. + +**Returns:** `{ peer: PeerID, counter: number }` creation identifier + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +const { peer, counter } = node.creationId(); +``` + + + + +```typescript no_run +creator(): PeerID +``` + + +Returns the peer ID that created this node (equivalent to `creationId().peer`). + +**Returns:** `PeerID` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +const author = node.creator(); +// author == doc.peerIdStr +``` + + + + +```typescript no_run +getLastMoveId(): { peer: PeerID, counter: number } | undefined +``` + + +Returns the OpID of the most recent move operation for this node, or `undefined` if the node has never been moved. + +**Returns:** Creation/move OpID or `undefined` + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +const lastMove = node.getLastMoveId(); +``` + + + + +```typescript no_run +isDeleted(): boolean +``` + + +Checks if this node has been deleted. + +**Returns:** Boolean deletion status + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const node = tree.createNode(); + +// Usage example: +if (node.isDeleted()) { + console.log("Node is deleted"); +} +``` + + + +### LoroCounter + +A counter CRDT for collaborative numeric values. + + +```typescript no_run +increment(value: number): void +``` + + +Increments the counter. + +**Parameters:** + +- `value` - Amount to increment (default: 1) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); + +counter.increment(5); // +5 +``` + + + + +```typescript no_run +decrement(value: number): void +``` + + +Decrements the counter. + +**Parameters:** + +- `value` - Amount to decrement (default: 1) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); + +counter.decrement(3); // -3 +``` + + + + +```typescript no_run +value: number +``` + + +Gets the current counter value. + +**Returns:** Current numeric value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); + +console.log(`Counter value: ${counter.value}`); +``` + + + +### LoroMovableList + +A list optimized for move operations. Designed for frequent reordering (drag-and-drop) with good behavior under +concurrent moves (concurrent moves resolve to one final position). See [List and Movable List](/docs/tutorial/list). + +**📝 MovableList vs List:** + +- **Use MovableList** for: Drag-and-drop UIs, sortable lists, kanban boards +- **Use List** for: scenarios where the list items don't need to be moved +- **Key difference**: MovableList handles concurrent moves better (no duplicates) and supports set operations, List is more efficient in general. + + +```typescript no_run +move(from: number, to: number): void +``` + + +Moves an element from one position to another. + +**Parameters:** + +- `from` - Source index +- `to` - Target index + +**Example:** + +```typescript +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const movableList = doc.getMovableList("list"); +movableList.push("a"); +movableList.push("b"); +movableList.push("c"); +movableList.push("d"); +movableList.push("e"); + +movableList.move(0, 3); // Move first element to fourth position +``` + + + + +```typescript no_run +set(pos: number, value: Value | Container): void +``` + + +Replaces the value at a position. + +**Parameters:** + +- `pos` - Position to update +- `value` - New value + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const movableList = doc.getMovableList("list"); +movableList.push("a", "b", "c"); + +movableList.set(0, "Updated value"); +``` + + + + +```typescript no_run +setContainer(pos: number, container: T): T +``` + + +Replaces the value with a container. + +**Parameters:** + +- `pos` - Position to update +- `container` - New container + +**Returns:** The set container + +**Example:** + +```ts threeslash +import { LoroDoc, LoroText } from "loro-crdt"; +const doc = new LoroDoc(); +const movableList = doc.getMovableList("list"); +movableList.push("placeholder"); + +const text = movableList.setContainer(0, new LoroText()); +``` + + + +## Synchronization + +Import/export updates over any transport and choose the right encoding for speed and size. See [Sync Tutorial](/docs/tutorial/sync) and [Encoding & Export Modes](/docs/tutorial/encoding). + +- **Import order**: Loro handles out-of-order updates automatically +- **Auto-commit**: Import and export operations trigger automatic commits + +### Import/Export Patterns + +#### Basic Synchronization + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +// Peer A: Export updates +const doc1 = new LoroDoc(); +doc1.getText("text").insert(0, "Hello"); +const updates = doc1.export({ mode: "update" }); + +// Peer B: Import updates +const doc2 = new LoroDoc(); +doc2.import(updates); +// now doc2.getText("text").toString() === "Hello" +``` + +#### Continuous Sync + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); + +// Usage example: +// Set up bidirectional sync +doc1.subscribeLocalUpdates((updates) => { + doc2.import(updates); +}); + +doc2.subscribeLocalUpdates((updates) => { + doc1.import(updates); +}); +``` + +Performance tips: + +- Prefer `mode: "update"` with a `VersionVector` to sync incrementally. +- Use `mode: "shallow-snapshot"` when you only need current state; it strips history for faster import/load. +- Loro’s LSM-based encoding and Eg-walker-inspired merge keep import/export fast, even for large histories. + See [Encoding](/docs/tutorial/encoding) and v1.0 performance notes: https://loro.dev/blog/v1.0 + +#### Network Sync with WebSocket + +```ts no_run threeslash +import { LoroDoc } from "loro-crdt"; + +// Assume we have: +declare const ws: { + send: (data: Uint8Array) => void; + on: (event: string, handler: (data: any) => void) => void; +}; + +// Client side +const doc = new LoroDoc(); + +// Send local updates to server +doc.subscribeLocalUpdates((updates) => { + ws.send(updates); +}); + +// Receive updates from server +ws.on("message", (data) => { + doc.import(new Uint8Array(data)); +}); +``` + +### Shallow Snapshots + +Shallow snapshots allow for efficient storage by garbage collecting deleted operations. + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Create shallow snapshot +const frontiers = doc.frontiers(); +const shallowSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: frontiers, +}); + +// Import shallow snapshot +const newDoc = new LoroDoc(); +newDoc.import(shallowSnapshot); +``` + +--- + +## Version Control + +### Time Travel + +Navigate through document history: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Save current version +const v1 = doc.frontiers(); + +// Make changes +doc.getText("text").insert(0, "New text"); + +// Save new version +const v2 = doc.frontiers(); + +// Travel back +doc.checkout(v1); +console.log(doc.getText("text").toString()); // Original text + +// Travel forward +doc.checkout(v2); +console.log(doc.getText("text").toString()); // New text + +// Return to latest +doc.checkoutToLatest(); +``` + +### Forking + +Create document branches: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Fork at current state +const fork1 = doc.fork(); +fork1.getText("text").insert(0, "Fork 1 changes"); + +// Fork at specific version +const historicalVersion = doc.frontiers(); +const fork2 = doc.forkAt(historicalVersion); +fork2.getText("text").insert(0, "Fork 2 changes"); + +// Original document remains unchanged +``` + +### Version Vectors + +Track document versions: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const vv = doc.version(); +console.log(`Document has ${vv.length()} peers`); +``` + +--- + +## Events & Subscriptions + +### Event Structure + +```typescript no_run +interface LoroEventBatch { + by: "local" | "import" | "checkout"; + origin?: string; + currentTarget?: ContainerID; + events: LoroEvent[]; + from: Frontiers; + to: Frontiers; +} + +interface LoroEvent { + target: ContainerID; + diff: Diff; + path: Path; +} +``` + +### Diff Types + +Different containers produce different diff types: + +#### TextDiff + + +```typescript no_run +type TextDiff = { + type: "text" + diff: Delta[] +} +``` +Represents changes to text content using Delta format. + +**Properties:** + +- `type` - Always "text" for text diffs +- `diff` - Array of Delta operations (insert, delete, retain) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// Example +text.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "text") { + event.diff.diff.forEach((delta) => { + if (delta.insert) { + console.log(`Inserted: "${delta.insert}"`); + } + if (delta.delete) { + console.log(`Deleted ${delta.delete} characters`); + } + }); + } + } +}); +``` + + + +#### ListDiff + + +```typescript no_run +type ListDiff = { + type: "list" + diff: Delta<(Value | Container)[]>[] +} +``` +Represents changes to list content using Delta format. + +**Properties:** + +- `type` - Always "list" for list diffs +- `diff` - Array of Delta operations on list items + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const list = doc.getList("list"); + +// Example +list.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "list") { + event.diff.diff.forEach((delta) => { + if (delta.insert) { + console.log(`Inserted items:`, delta.insert); + } + }); + } + } +}); +``` + + + +#### MapDiff + + +```typescript no_run +type MapDiff = { + type: "map" + updated: Record +} +``` +Represents changes to map content. + +**Properties:** + +- `type` - Always "map" for map diffs +- `updated` - Record of key-value changes (undefined means deleted) + +**Example:** + +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Example +map.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "map") { + Object.entries(event.diff.updated).forEach(([key, value]) => { + if (value === undefined) { + console.log(`Deleted key: ${key}`); + } else { + console.log(`Updated key: ${key} = ${value}`); + } + }); + } + } +}); +``` + + + +#### TreeDiff + + +```typescript no_run +type TreeDiff = { + type: "tree" + diff: TreeDiffItem[] +} + +type TreeDiffItem = +| { +target: TreeID +action: "create" +parent: TreeID | undefined +index: number +fractionalIndex: string +} +| { +target: TreeID +action: "delete" +oldParent: TreeID | undefined +oldIndex: number +} +| { +target: TreeID +action: "move" +parent: TreeID | undefined +index: number +fractionalIndex: string +oldParent: TreeID | undefined +oldIndex: number +} + +```` +Represents changes to tree structure. + +**Properties:** +- `type` - Always "tree" for tree diffs +- `diff` - Array of TreeDiffItem operations (create, delete, move) + +**TreeDiffItem Actions:** +- `create` - Node creation with parent and position +- `delete` - Node deletion with old parent and position +- `move` - Node movement with old and new positions + +**Example:** +```ts threeslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); + +// Example +tree.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "tree") { + event.diff.diff.forEach(item => { + switch (item.action) { + case "create": + console.log(`Created node ${item.target}`); + break; + case "move": + console.log(`Moved node ${item.target}`); + break; + case "delete": + console.log(`Deleted node ${item.target}`); + break; + } + }); + } + } +}); +```` + + + +### Deep Subscription + +Subscribe to nested changes: + +```ts threeslash +import { LoroDoc } from "loro-crdt"; + +const doc = new LoroDoc(); +// Subscribe to specific container +const text = doc.getText("text"); +text.subscribe((event) => { + console.log("Text changed:", event); +}); + +// Subscribe with deep observation +doc.subscribe((event) => { + // Path shows the location of the change + event.events.forEach((e) => { + console.log("Change path:", e.path); + console.log("Container:", e.target); + console.log("Diff:", e.diff); + }); +}); +``` + +--- + +## Undo/Redo + +Local undo operates on your own changes without breaking collaboration. See [Undo/Redo](/docs/advanced/undo) for design details and caveats. + +### UndoManager + +Provides local undo/redo functionality. + +**⚠️ Important Notes:** + +- **Local-only**: UndoManager only undoes the local user's operations, not remote operations +- **Origin filtering**: Use `excludeOriginPrefixes` to exclude certain operations (e.g., sync operations) from undo +- **Cursor restoration**: Use `onPush`/`onPop` callbacks to save and restore cursor positions + + +```typescript no_run +constructor(doc: LoroDoc, config: UndoConfig) +``` + + +Creates a new UndoManager instance. + +**Parameters:** + +- `doc` - The LoroDoc to manage undo/redo for +- `config` - Configuration options + - `mergeInterval?` - Time in ms to merge consecutive operations (default: 1000) + - `maxUndoSteps?` - Maximum number of undo steps to keep (default: 100) + - `excludeOriginPrefixes?` - Array of origin prefixes to exclude from undo + - `onPush?` - Callback when adding to undo stack + - `onPop?` - Callback when undoing/redoing + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const undo = new UndoManager(doc, { + mergeInterval: 1000, + maxUndoSteps: 100, + excludeOriginPrefixes: ["sync-"], +}); +``` + + + + +```typescript no_run +undo(): boolean +``` + + +Undo the last operation. + +**Returns:** True if undo was successful + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); + +// Usage example: +const success = undo.undo(); +console.log(success); // true +``` + + + + +```typescript no_run +redo(): boolean +``` + + +Redo the last undone operation. + +**Returns:** True if redo was successful + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); +undo.undo(); + +// Usage example: +const success = undo.redo(); +console.log(success); // true +``` + + + + +```typescript no_run +canUndo(): boolean +``` + + +Check if undo is available. + +**Returns:** True if can undo + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +if (undo.canUndo()) { + undo.undo(); +} +``` + + + + +```typescript no_run +canRedo(): boolean +``` + + +Check if redo is available. + +**Returns:** True if can redo + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +if (undo.canRedo()) { + undo.redo(); +} +``` + + + + +```typescript no_run +peer(): PeerID +``` + + +Get the peer ID of the undo manager. + +**Returns:** The peer ID + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +const peerId = undo.peer(); +console.log(peerId); // e.g., "123456" +``` + + + + +```typescript no_run +setMaxUndoSteps(steps: number): void +``` + + +Set the maximum number of undo steps. + +**Parameters:** + +- `steps` - Maximum number of undo steps + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.setMaxUndoSteps(50); +``` + + + + +```typescript no_run +setMergeInterval(interval: number): void +``` + + +Set the merge interval for grouping operations. + +**Parameters:** + +- `interval` - Merge interval in milliseconds + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.setMergeInterval(2000); // 2 seconds +``` + + + + +```typescript no_run +groupStart(): void +``` + + +Begin a manual grouping of subsequent commits into a single undo step. + +**Behavior:** + +- Wrap consecutive `doc.commit()` calls so they undo together +- Calling `groupStart` again before `groupEnd` throws and leaves the current group unchanged +- Conflicting remote imports may automatically end the group and split the undo item + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); + +undo.groupStart(); + +text.update("hello", undefined); +doc.commit(); + +text.update("hello world", undefined); +doc.commit(); + +undo.groupEnd(); + +undo.undo(); + +console.log(text.toString()); // "" +``` + + + + +```typescript no_run +groupEnd(): void +``` + + +Close the active manual group and enqueue the grouped operations as a single undo item. + +**Behavior:** + +- Must be paired with a prior `groupStart` +- Safe to call after the group was auto-closed by a conflicting remote import (becomes a no-op) +- Non-conflicting remote updates from other peers remain outside the undo item but do not break the group + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); +const text = doc.getText("text"); + +undo.groupStart(); + +text.update("hello", undefined); +doc.commit(); +text.update("hello world", undefined); +doc.commit(); + +const snapshot = doc.export({ mode: "snapshot" }); +const doc2 = new LoroDoc(); +doc2.import(snapshot); +doc2.getText("text2").update("hello world world", undefined); // touches a different container +doc2.commit(); +const update = doc2.export({ mode: "update" }); + +doc.import(update); // remote, non-conflicting change + +text.update("hello world world world", undefined); +doc.commit(); + +undo.groupEnd(); + +undo.undo(); + +console.log(text.toString()); // "" +``` + + + + +```typescript no_run +addExcludeOriginPrefix(prefix: string): void +``` + + +Add a prefix to exclude from undo stack. + +**Parameters:** + +- `prefix` - Origin prefix to exclude + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.addExcludeOriginPrefix("sync-"); +undo.addExcludeOriginPrefix("import-"); +``` + + + + +```typescript no_run +clear(): void +``` + + +Clear the undo and redo stacks. + +**Example:** + +```ts threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +const undo = new UndoManager(doc, {}); + +// Usage example: +undo.clear(); +``` + + + +### Custom Undo Handlers + +Handle cursor restoration and side effects: + +```typescript no_run threeslash +import { LoroDoc, UndoManager } from "loro-crdt"; +const doc = new LoroDoc(); +declare function saveCursorPositions(): any; +declare function restoreCursorPositions(cursors: any): void; + +// Usage example: +const undo = new UndoManager(doc, { + onPush: (isUndo, counterRange, event) => { + // Save cursor positions when adding to undo stack + const cursors = saveCursorPositions(); + return { + value: doc.toJSON(), + cursors: cursors, + }; + }, + + onPop: (isUndo, { value, cursors }, counterRange) => { + // Restore cursor positions when undoing + restoreCursorPositions(cursors); + }, +}); +``` + +--- + +## Types & Interfaces + +Reference for core types used across the API. For conceptual background, see [Containers](/docs/concepts/container), [Version Vector](/docs/concepts/version_vector), [Frontiers](/docs/concepts/frontiers), and the [Versioning Deep Dive](/docs/advanced/version_deep_dive). + +### Core Types + +```typescript no_run +// Peer identifier +type PeerID = `${number}`; + +// Container identifier +type ContainerID = + | `cid:root-${string}:${ContainerType}` + | `cid:${number}@${PeerID}:${ContainerType}`; + +// Tree node identifier +type TreeID = `${number}@${PeerID}`; + +// Operation identifier +type OpId = { + peer: PeerID; + counter: number; +}; + +// Container types +type ContainerType = + | "Text" + | "Map" + | "List" + | "Tree" + | "MovableList" + | "Counter"; + +// Value types +type Value = + | ContainerID + | string + | number + | boolean + | null + | { [key: string]: Value } + | Uint8Array + | Value[] + | undefined; +``` + +### Version Types + +Loro uses two complementary version representations: Version Vectors (per-peer counters) and Frontiers (a compact set of heads). See [Version Vector](/docs/concepts/version_vector) and [Frontiers](/docs/concepts/frontiers). For the full DAG model, see [Version Deep Dive](/docs/advanced/version_deep_dive). + +```typescript no_run +// Version vector class +class VersionVector { + constructor( + value: Map | Uint8Array | VersionVector | undefined | null, + ); + static parseJSON(version: Map): VersionVector; + toJSON(): Map; + encode(): Uint8Array; + static decode(bytes: Uint8Array): VersionVector; + get(peer_id: number | bigint | `${number}`): number | undefined; + compare(other: VersionVector): number | undefined; + setEnd(id: { peer: PeerID; counter: number }): void; + setLast(id: { peer: PeerID; counter: number }): void; + remove(peer: PeerID): void; + length(): number; +} + +// Frontiers represent a specific version +type Frontiers = OpId[]; + +// ID span for range queries +type IdSpan = { + peer: PeerID; + counter: number; + length: number; +}; +``` + +### Change Types + +```typescript no_run +// Change metadata +interface Change { + peer: PeerID; + counter: number; + lamport: number; + length: number; + timestamp: number; // Unix timestamp in seconds + deps: OpId[]; + message: string | undefined; +} + +// Change modifier for pre-commit hooks +interface ChangeModifier { + setMessage(message: string): this; + setTimestamp(timestamp: number): this; +} +``` + +### Cursor Types + +Stable cursors survive concurrent edits and resolve to absolute positions on demand. See [Cursor and Stable Positions](/docs/concepts/cursor_stable_positions) and [Cursor tutorial](/docs/tutorial/cursor). + +```typescript no_run +// Stable position in containers +class Cursor { + containerId(): ContainerID; + pos(): OpId | undefined; + side(): Side; // -1 | 0 | 1 + encode(): Uint8Array; + static decode(data: Uint8Array): Cursor; +} + +// Cursor side affinity +type Side = -1 | 0 | 1; +``` + +### Delta Type + +Delta is a popular rich-text operation format (e.g., Quill). LoroText can export/import Delta; see [Text](/docs/tutorial/text). + +```typescript no_run +// Rich text delta operations +type Delta = + | { + insert: T; + attributes?: { [key in string]: {} }; + retain?: undefined; + delete?: undefined; + } + | { + delete: number; + attributes?: undefined; + retain?: undefined; + insert?: undefined; + } + | { + retain: number; + attributes?: { [key in string]: {} }; + delete?: undefined; + insert?: undefined; + }; +``` + +--- + +## Utility Functions + +Small helpers for type checks and IDs. See [Container IDs](/docs/advanced/cid) and [Versioning Deep Dive](/docs/advanced/version_deep_dive) for frontiers/version encoding. + +### Frontier Encoding + + +```typescript no_run +encodeFrontiers(frontiers: OpId[]): Uint8Array +``` + + +Encode frontiers for efficient transmission. + +**Parameters:** + +- `frontiers` - Array of operation IDs representing frontiers + +**Returns:** Encoded bytes + +**Example:** + +```ts threeslash +import { LoroDoc, encodeFrontiers } from "loro-crdt"; + +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +const encoded = encodeFrontiers(frontiers); +// Send encoded to remote peers +``` + + + + +```typescript no_run +decodeFrontiers(bytes: Uint8Array): OpId[] +``` + + +Decode frontiers from bytes. + +**Parameters:** + +- `bytes` - Encoded frontier bytes + +**Returns:** Array of operation IDs + +**Example:** + +```typescript no_run threeslash +import { decodeFrontiers } from "loro-crdt"; + +declare const encodedData: Uint8Array; +const frontiers = decodeFrontiers(encodedData); +console.log(frontiers); // [{ peer: "1", counter: 10 }, ...] +``` + + + +### Debugging + + +```typescript no_run +setDebug(): void +``` + + +Enable debug mode for detailed logging. + +**Example:** + +```ts threeslash +import { setDebug } from "loro-crdt"; + +// Enable debug logging +setDebug(); +``` + + + + +```typescript no_run +LORO_VERSION(): string +``` + + +Get the current Loro version. + +**Returns:** Version string + +**Example:** + +```ts threeslash +import { LORO_VERSION } from "loro-crdt"; + +const version = LORO_VERSION(); +console.log("Loro version:", version); +``` + + + +### Import Blob Metadata + + +```typescript no_run +decodeImportBlobMeta(blob: Uint8Array, check_checksum: boolean): ImportBlobMetadata +``` + + +Decode metadata from an import blob. + +**Parameters:** + +- `blob` - The import blob bytes +- `check_checksum` - Whether to verify checksum + +**Returns:** Import blob metadata + +**Example:** + +```typescript no_run threeslash +import { decodeImportBlobMeta } from "loro-crdt"; + +declare const blob: Uint8Array; +const metadata = decodeImportBlobMeta(blob, true); +console.log("Blob metadata:", metadata); +``` + + + +--- + +## EphemeralStore + +Manages ephemeral state like cursor positions and user presence. See [Ephemeral Store](/docs/tutorial/ephemeral) for concepts and usage patterns. Each entry uses timestamp-based LWW (Last-Write-Wins) for conflict resolution. + +**⚠️ Important:** + +- EphemeralStore is a separate CRDT without history - history/operations are NOT persisted +- Perfect for temporary state: cursor positions, selections, typing indicators +- Each peer's state auto-expires after the timeout period +- Uses Last-Write-Wins + +### EphemeralStore + + +```typescript no_run +constructor(timeout?: number) +``` + + +Creates a new EphemeralStore instance. + +**Parameters:** + +- `timeout` - Duration in milliseconds. A peer's state is considered outdated if its last update is older than this timeout. Default is 30000ms (30 seconds). + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; + +// Create ephemeral store with 30 second timeout +const store = new EphemeralStore(30000); +``` + + + + +```typescript no_run +set(key: K, value: T[K]): void +``` + + +Set an ephemeral value. + +**Parameters:** + +- `key` - The key to set +- `value` - The value to store + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +store.set("cursor", { line: 10, column: 5 }); +store.set("selection", { start: 0, end: 10 }); +store.set("user", { name: "Alice", color: "#ff0000" }); +``` + + + + +```typescript no_run +get(key: K): T[K] | undefined +``` + + +Get an ephemeral value. + +**Parameters:** + +- `key` - The key to get + +**Returns:** The stored value or undefined + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); + +// Usage example: +const cursor = store.get("cursor"); +console.log(cursor); // { line: 10 } +``` + + + + +```typescript no_run +delete(key: K): void +``` + + +Delete an ephemeral value. + +**Parameters:** + +- `key` - The key to delete + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); + +// Usage example: +store.delete("cursor"); +``` + + + + +```typescript no_run +getAllStates(): Partial +``` + + +Get all ephemeral states. + +**Returns:** All stored states + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); +store.set("user", { name: "Alice" }); + +// Usage example: +const allStates = store.getAllStates(); +console.log(allStates); +``` + + + + +```typescript no_run +encode(key: K): Uint8Array +``` + + +Encode a specific key's state for transmission. + +**Parameters:** + +- `key` - The key to encode + +**Returns:** Encoded bytes + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); + +// Usage example: +const encoded = store.encode("cursor"); +// Send encoded to remote peers +``` + + + + +```typescript no_run +encodeAll(): Uint8Array +``` + + +Encode all states for transmission. + +**Returns:** Encoded bytes + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); +store.set("user", { name: "Alice" }); + +// Usage example: +const encoded = store.encodeAll(); +// Send encoded to remote peers +``` + + + + +```typescript no_run +apply(bytes: Uint8Array): void +``` + + +Apply remote updates. + +**Parameters:** + +- `bytes` - Encoded updates from remote peer + +**Example:** + +```typescript no_run threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +declare const remoteData: Uint8Array; +store.apply(remoteData); +``` + + + + +```typescript no_run +keys(): string[] +``` + + +Get all keys in the store. + +**Returns:** Array of keys + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); +store.set("cursor", { line: 10 }); +store.set("user", { name: "Alice" }); + +// Usage example: +const allKeys = store.keys(); +console.log(allKeys); // ["cursor", "user"] +``` + + + + +```typescript no_run +subscribe(listener: EphemeralListener): () => void +``` + + +Subscribe to all ephemeral state changes. + +**Parameters:** + +- `listener` - Callback function for state changes + +**Returns:** Unsubscribe function + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +const unsubscribe = store.subscribe((event) => { + console.log("Ephemeral state changed:", event); +}); + +// Later, unsubscribe +unsubscribe(); +``` + + + + +```typescript no_run +subscribeLocalUpdates(listener: EphemeralLocalListener): () => void +``` + + +Subscribe to local ephemeral updates for syncing to remote peers. + +**Parameters:** + +- `listener` - Callback function that receives encoded updates + +**Returns:** Unsubscribe function + +**Example:** + +```typescript no_run threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +declare const websocket: { send: (data: Uint8Array) => void }; + +const unsubscribe = store.subscribeLocalUpdates((data) => { + // Send to remote peers + websocket.send(data); +}); + +// Later, unsubscribe +unsubscribe(); +``` + + + + +```typescript no_run +destroy(): void +``` + + +Clean up and destroy the ephemeral store. + +**Example:** + +```ts threeslash +import { EphemeralStore } from "loro-crdt"; +const store = new EphemeralStore(30000); + +// Usage example: +store.destroy(); +``` + + + +### Complete Example + +```typescript no_run threeslash +import { EphemeralStore } from "loro-crdt"; + +// Assume we have: +declare const websocket: { + send: (data: Uint8Array) => void; + on: (event: string, handler: (data: any) => void) => void; +}; + +const store = new EphemeralStore(30000); +const store2 = new EphemeralStore(30000); + +// Subscribe to local updates +store.subscribeLocalUpdates((data) => { + store2.apply(data); +}); + +// Subscribe to all updates +store2.subscribe((event) => { + console.log("event:", event); +}); + +// Set a value +store.set("key", "value"); + +// Encode the value +const encoded = store.encode("key"); + +// Apply the encoded value +store2.apply(encoded); +``` + +--- + +
+ + +# FILE: pages/docs/tutorial/counter.mdx + +--- +keywords: "counter crdt" +description: "how to use loro counter crdt and show all APIs of loro counter crdt." +--- + +# Counter + +Loro's Counter will add up all the applied values, and supports integers and floating point numbers. + +Here is how to use it: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +const counter = doc.getCounter("counter"); +counter.increment(1); +counter.increment(2); +counter.decrement(1); +expect(counter.value).toBe(2); +``` + + +# FILE: pages/docs/tutorial/ephemeral.mdx + +--- +keywords: "ephemeral, awareness, presence, collaborative" +description: "How to use Loro's ephemeral store feature to implement user awareness and online status management in real-time collaboration." +--- + +# Ephemeral Store + +In real-time collaborative scenarios, Presence information is just as important as maintaining document consistency across peers through CRDTs. This includes information such as the current collaborator's username, mouse pointer position, or selected objects. We need a mechanism that doesn't persist in the CRDT Document but remains ephemeral, allowing collaborators to perceive each other's presence for better coordination and to avoid conflicts when multiple users edit the same object. This is why we've introduced the Ephemeral Store. + +![](/images/ephemeral.png) + +Since Ephemeral information is primarily used for real-time collaboration, we've chosen a simple yet effective approach. The Ephemeral Store is a timestamp-based, last-write-wins key-value store. Each entry maintains its own timestamp of the last update, enabling the system to send only the updated entry content rather than the complete current state. + +## Example + +```ts twoslash +import { EphemeralStore, EphemeralStoreEvent } from "loro-crdt"; +import { expect } from "expect"; + +const store = new EphemeralStore(); +// Set ephemeral data +store.set("loro-prosemirror", { + anchor: { pos: 0 }, + focus: { pos: 5 }, + user: "Alice", +}); +store.set("online-users", ["Alice", "Bob"]); + +expect(store.get("online-users")).toEqual(["Alice", "Bob"]); +// Encode only the data for `loro-prosemirror` +const encoded = store.encode("loro-prosemirror"); + +store.subscribe((e: EphemeralStoreEvent) => { + // Listen to changes from `local`, `remote`, or `timeout` events +}); +``` + +## API + +- `constructor(timeout)`: + Creates a new EphemeralStore instance with an optional timeout parameter (default: 30000ms). The timeout determines how long ephemeral data remains valid before being automatically removed. + +- `set(key, value)`: + Sets a value for the specified key in the ephemeral store. If the key already exists, its value will be updated. + +- `get(key)`: + Retrieves the current value for the specified key, or returns `undefined` if the key doesn't exist. + +- `delete(key)`: + Removes the specified key and its associated value from the ephemeral store. + +- `getAllStates()`: + Returns all current key-value pairs in the ephemeral store. + +- `keys()`: + Returns an array of all keys currently in the ephemeral store. + +- `encode(key)`: + Encodes the value associated with the specified key into a binary format that can be transmitted to other peers. + +- `encodeAll()`: + Encodes all key-value pairs in the ephemeral store into a binary format. + +- `apply(bytes)`: + Applies encoded ephemeral data received from other peers to the local ephemeral store. + +- `subscribe((event: EphemeralStoreEvent)=>void)`: + Registers a listener function that will be called whenever the ephemeral store is updated, either from local changes, remote changes, or timeout events. + + ```ts + interface EphemeralStoreEvent { + // The source of the event: local changes, imported from remote, or timeout expiration + by: "local" | "import" | "timeout"; + // Array of keys that were newly added + added: string[]; + // Array of keys that had their values updated + updated: string[]; + // Array of keys that were removed + removed: string[]; + } + ``` + +- `subscribeLocalUpdates((bytes: Uint8Array) => void)`: + Registers a listener that will be called only for local updates to the ephemeral store. + + ```ts + // you need maintain the Subscription to avoid gc + const _sub1 = ephemeral1.subscribeLocalUpdates((update) => { + ephemeral2.apply(update); + }); + + const _sub2 = ephemeral2.subscribeLocalUpdates((update) => { + ephemeral1.apply(update); + }); + ``` + + +# FILE: pages/docs/tutorial/loro_doc.mdx + +# Getting Started with LoroDoc + +LoroDoc is the main entry point for almost all Loro functionality. It serves as a container manager and coordinator that provides: + +1. **Container Management**: Create and manage different types of CRDT containers (Text, List, Map, Tree, MovableList) +2. **Version Control**: Track document history, checkout versions, and manage branches +3. **Event System**: Subscribe to changes at both document and container levels +4. **Import/Export**: Save and load documents/updates in various formats + +## Basic Usage + +First, let's create a new LoroDoc instance: + +```ts twoslash +import { + LoroDoc, + LoroText, + LoroList, + LoroMap, + LoroTree, + LoroMovableList, +} from "loro-crdt"; +// ---cut--- +// Create a new document with a random peer ID +const doc = new LoroDoc(); + +// Or set a specific peer ID +doc.setPeerId("1"); + +// Create containers +const text = doc.getText("text"); +const list = doc.getList("list"); +const map = doc.getMap("map"); +const tree = doc.getTree("tree"); +const movableList = doc.getMovableList("tasks"); +``` + +To model a document with the following format: + +```json +{ + "meta": { + "title": "Document Title", + "createdBy": "Author" + }, + "content": "Article", + "comments": [ + { + "user": "userId", + "comment": "comment" + } + ] +} +``` + +```ts twoslash +import { LoroDoc, LoroMap } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const meta = doc.getMap("meta"); +meta.set("title", "Document Title"); +meta.set("createdBy", "Author"); +doc.getText("content").insert(0, "Article"); +const comments = doc.getList("comments"); +const comment1 = comments.insertContainer(0, new LoroMap()); +comment1.set("user", "userId"); +comment1.set("comment", "comment"); +``` + +## Supported Data Types (LoroValue) + +Loro supports storing various data types in its containers. The `LoroValue` type represents all possible values that can be stored in a LoroDoc: + +```ts +type LoroValue = + | null // Null values + | boolean // true or false + | number // Numeric values (both integer and floating point) + | string // Text values + | Uint8Array // Binary data + | LoroValue[] // Arrays (nested) + | { [key: string]: LoroValue } // Objects/Maps (nested) +``` + +### Examples of Storing Different Data Types + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const map = doc.getMap("data"); + +// Primitive types +map.set("name", "Alice"); // string +map.set("age", 30); // integer number +map.set("score", 95.5); // floating point number +map.set("isActive", true); // boolean +map.set("metadata", null); // null + +// Binary data +const binaryData = new Uint8Array([1, 2, 3, 4, 5]); +map.set("bytes", binaryData); // Uint8Array + +// Nested objects (plain JavaScript objects) +map.set("profile", { + email: "alice@example.com", + settings: { + theme: "dark", + notifications: true + } +}); + +// Arrays +map.set("tags", ["javascript", "typescript", "loro"]); + +// Mixed nested structures +map.set("complex", { + items: [1, 2, { nested: true }], + binary: new Uint8Array([255, 128]), + active: false +}); +``` + +Note: When you need to store a CRDT container (like LoroText, LoroList, etc.) within another container, use `setContainer()` or `insertContainer()` methods instead of regular `set()` or `insert()`. This creates a proper sub-container relationship that maintains CRDT properties. If a child container under a Map may be created lazily by multiple peers at the same key, use the `ensureMergeable*` methods instead; see [Containers](/docs/concepts/container) and [Composing CRDTs](/docs/tutorial/composition). + +## Container Types + +LoroDoc supports several container types: + +1. **Text** - For rich text editing +2. **List** - For ordered collections +3. **Map** - For key-value pairs +4. **Tree** - For hierarchical data structures +5. **MovableList** - For lists with movable items + +Let's look at how to use each type: + +### Text Container + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello"); +text.insert(5, " World!"); +console.log(text.toString()); // "Hello World!" + +// Rich text support +doc.configTextStyle({ + bold: { expand: "after" }, + link: { expand: "none" }, +}); +text.mark({ start: 0, end: 5 }, "bold", true); +``` + +### List Container + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const list = doc.getList("list"); +list.insert(0, "first"); +list.insert(1, "second"); +console.log(list.toArray()); // ["first", "second"] + +// Nested containers +const nestedText = list.insertContainer(2, new LoroText()); +nestedText.insert(0, "nested text"); +``` + +### Map Container + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const map = doc.getMap("map"); +map.set("name", "John"); +map.set("age", 30); +console.log(map.get("name")); // "John" + +// Nested containers +const userText = map.setContainer("bio", new LoroText()); +userText.insert(0, "Software Engineer"); +``` + +### Tree Container + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const tree = doc.getTree("tree"); +const root = tree.createNode(); +root.data.set("name", "Root"); + +const child1 = root.createNode(); +child1.data.set("name", "Child 1"); + +const child2 = root.createNode(); +child2.data.set("name", "Child 2"); +``` + +### MovableList Container + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const movableList = doc.getMovableList("tasks"); +movableList.insert(0, "Task 1"); +movableList.insert(1, "Task 2"); +movableList.move(0, 1); // Move Task 1 after Task 2 +``` + +## Collaboration Features + +LoroDoc can be used for real-time collaboration. Here's how to sync changes between peers: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +declare const otherDoc: LoroDoc; +// ---cut--- +// First peer +const doc1 = new LoroDoc(); +doc1.setPeerId("1"); +const text1 = doc1.getText("text"); + +// Second peer +const doc2 = new LoroDoc(); +doc2.setPeerId("2"); +const text2 = doc2.getText("text"); + +// Set up two-way sync +doc1.subscribeLocalUpdates((updates) => { + doc2.import(updates); +}); + +doc2.subscribeLocalUpdates((updates) => { + doc1.import(updates); +}); + +// Now changes in doc1 will be reflected in doc2 and vice versa +text1.insert(0, "Hello"); +doc1.commit(); +await Promise.resolve(); // await for the event to be emitted +text2.insert(5, " World!"); +doc2.commit(); +``` + +## Undo/Redo Support + +Loro provides built-in undo/redo functionality: + +```ts twoslash +import { UndoManager, LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const undoManager = new UndoManager(doc, { + maxUndoSteps: 100, + mergeInterval: 1000, +}); + +const text = doc.getText("text"); +// Make some changes +text.insert(0, "Hello"); +doc.commit(); + +// Undo the changes +if (undoManager.canUndo()) { + undoManager.undo(); +} + +// Redo the changes +if (undoManager.canRedo()) { + undoManager.redo(); +} +``` + +## Exporting and Importing + +You can save and load the document state: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Export the document +const snapshot = doc.export({ mode: "snapshot" }); + +// Create a new document from the snapshot +const newDoc = LoroDoc.fromSnapshot(snapshot); + +const doc2 = new LoroDoc(); +// Or import into an existing document +doc2.import(snapshot); +``` + +### Shallow Import/Export + +Shallow import/export is a feature that allows you to create and share document snapshots without including the complete history. This is particularly useful for: + +1. Reducing the size of exported data +2. Sharing the document with others without revealing the complete history +3. Speedup the import/export process + +Here's how to use shallow export: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Export a shallow snapshot that only include the history since `doc.oplogFrontiers()` +// It works like `git clone --depth=1`, where the exported data only contain the most recent ops. +const shallowSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.oplogFrontiers(), +}); + +// Check if a document is shallow +const isShallow = doc.isShallow(); + +// Get the version since which the history is available +const sinceVersion = doc.shallowSinceVV(); +// Or get it in frontiers format +const sinceFrontiers = doc.shallowSinceFrontiers(); +``` + +Note: A shallow document only contains history after a certain version point. Operations before the shallow start point are not included, but the document remains fully functional for collaboration. + +### Redacting Sensitive Content + +Loro allows you to redact specific segments of document history while preserving the rest. This is particularly useful when: + +1. A user accidentally pastes sensitive information (like passwords or API keys) into the document +2. You need to remove just the sensitive part of the history while keeping older and newer edits intact +3. You want to share document history with sensitive segments sanitized + +Here's how to use the redaction functionality: + +```ts no_run twoslash +import { LoroDoc, redactJsonUpdates } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); + +// Create some content to be redacted +const text = doc.getText("text"); +text.insert(0, "Sensitive information"); +doc.commit(); + +const map = doc.getMap("map"); +map.set("password", "secret123"); +map.set("public", "public information"); +doc.commit(); + +// Export JSON updates +const jsonUpdates = doc.exportJsonUpdates(); + +// Define version range to redact (redact the text content) +const versionRange = { + "1": [0, 21], // Redact the "Sensitive information" +}; + +// Apply redaction +const redactedJson = redactJsonUpdates(jsonUpdates, versionRange); + +// Create a new document with redacted content +const redactedDoc = new LoroDoc(); +redactedDoc.importJsonUpdates(redactedJson); + +// The text content is now redacted with replacement characters +console.log(redactedDoc.getText("text").toString()); +// Outputs: "���������������������" + +// Map operations after the redacted range remain intact +console.log(redactedDoc.getMap("map").get("password")); // "secret123" +console.log(redactedDoc.getMap("map").get("public")); // "public information" +``` + +Redaction applies these rules to preserve document structure while removing sensitive content: + +- Preserves delete and move operations +- Replaces text insertion content with Unicode replacement characters '�' +- Substitutes list and map insert values with null +- Maintains structure of nested containers +- Replaces text mark values with null +- Preserves map keys and text annotation keys + +Note that redaction doesn't remove the operations completely - it just replaces the sensitive content with placeholders. If you need to completely remove portions of history, see the section on shallow snapshots in the [Tips](./tips.md) section. + +#### Important: Synchronization Considerations + +Both redaction and shallow snapshots maintain future synchronization consistency, but your application is responsible for ensuring all peers get the sanitized version. Otherwise, old instances of the document with sensitive information will still exist on other peers. + +## Event Subscription + +Subscribe to changes in the document: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.subscribe((event) => { + console.log("Document changed:", event); +}); + +const text = doc.getText("text"); +// Container-specific subscription +text.subscribe((event) => { + console.log("Text changed:", event); +}); +``` + +### Event Emission + +Events in LoroDoc are emitted only after a transaction is committed, and importantly, the events are emitted after a microtask. This means you need to await a microtask if you want to handle the events immediately after a commit. + +1. Explicitly calling `doc.commit()`: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// Subscribe to changes +doc.subscribe((event) => { + console.log("Change event:", event); +}); + +text.insert(0, "Hello"); // No event emitted yet +doc.commit(); // Event will be emitted after a microtask + +// If you need to wait for the event: +await Promise.resolve(); // Now the event has been emitted +``` + +2. Implicitly through certain operations: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +declare const someData: Uint8Array; +declare const someVersion: any; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); + +// These operations trigger implicit commits: +doc.export({ mode: "snapshot" }); // Implicit commit +doc.import(someData); // Implicit commit +doc.checkout(someVersion); // Implicit commit +``` + +You can also specify additional information when committing: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.commit({ + origin: "user-edit", // Mark the event source + message: "Add greeting", // Like a git commit message + timestamp: Date.now(), // Custom timestamp +}); +await Promise.resolve(); // Wait for event if needed +``` + +Note: Multiple operations before a `commit` are batched into a single event. This helps reduce event overhead and provides atomic changes. The event will still be emitted after a microtask, regardless of whether the commit was explicit or implicit. + +## Version Control and History + +LoroDoc provides powerful version control features that allow you to track and manage document history: + +### Version Representation + +Loro uses two ways to represent versions: + +1. **Version Vector**: A map from peer ID to counter + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Get current version vector +const vv = doc.version(); + +// Get oplog version vector (latest known version) +const oplogVv = doc.oplogVersion(); +``` + +2. **Frontiers**: A list of operation IDs that represent the latest operations from each peer. This is compacter than version vector. In most of the cases, it only has 1 element. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getMap("map").set("text", "Hello"); +// Get current frontiers +const frontiers = doc.frontiers(); + +// Get oplog frontiers (latest known version) +const oplogFrontiers = doc.oplogFrontiers(); // { "0": 0 } +``` + +### Checkout and Time Travel + +You can navigate through document history using checkout: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Save current version +const frontiers = doc.frontiers(); +const text = doc.getText("text"); + +// Make some changes +text.insert(0, "Hello World!"); + +// Checkout to previous version +doc.checkout(frontiers); + +// Return to latest version +doc.checkoutToLatest(); +// or +doc.attach(); +``` + +Note: After checkout, the document enters "detached" mode. In this mode: + +- The document is not editable by default +- Import operations are recorded but not applied to the document state +- You need to call `attach()` or `checkoutToLatest()` to go back to the latest version and make it editable again + +### Detached Mode + +The document enters "detached" mode after a `checkout` operation or when explicitly calling `doc.detach()`. In detached mode, the document state is not synchronized with the latest version in the OpLog. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Check if document is in detached mode +console.log(doc.isDetached()); // false + +// Explicitly detach the document +doc.detach(); +console.log(doc.isDetached()); // true + +// Return to attached mode +doc.attach(); +console.log(doc.isDetached()); // false +``` + +By default, editing is disabled in detached mode. However, you can enable it: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Enable editing in detached mode +doc.setDetachedEditing(true); +console.log(doc.isDetachedEditingEnabled()); // true +``` + +#### Key Behaviors in Detached Mode + +1. **Import Operations** + - Operations imported via `doc.import()` are recorded in the OpLog + - These operations are not applied to the document state until checkout + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const oldDoc = new LoroDoc(); +oldDoc.getMap("map").set("name", "John"); +const updates = oldDoc.export({ mode: "update" }); +const doc = new LoroDoc(); +// In detached mode +doc.import(updates); // Updates are stored but not applied +doc.checkoutToLatest(); // Now updates are applied +``` + +2. **Version Management** + - Each checkout uses a different PeerID to prevent conflicts + - The document maintains two version states: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Current state version +const stateVersion = doc.version(); +// Latest known version in OpLog +const latestVersion = doc.oplogVersion(); +``` + +3. **Forking** + - You can create a new document at a specific version: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello"); +// Fork at current frontiers +const forkedDoc = doc.fork(); +// Or fork at specific frontiers +const forkedAtVersion = doc.forkAt([{ peer: "0", counter: 1 }]); +console.log(forkedAtVersion.getText("text").toString()); // "He" +``` + +#### Common Use Cases + +1. **Time Travel and History Review** + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +declare const text: any; +// ---cut--- +const doc = new LoroDoc(); +// Save current version +const frontiers = doc.frontiers(); + +// Make changes +text.insert(0, "New content"); + +// Review previous version +doc.checkout(frontiers); + +// Return to latest version +doc.checkoutToLatest(); +``` + +2. **Branching** + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Enable detached editing +doc.setDetachedEditing(true); + +// Create a branch +const branch = doc.fork(); + +// Make changes in branch +const branchText = branch.getText("text"); +branchText.insert(0, "Branch changes"); +``` + +## Subscription and Sync + +### Local Updates Subscription + +Subscribe to local changes for syncing between peers: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +declare const otherDoc: LoroDoc; +// ---cut--- +const doc = new LoroDoc(); +// Subscribe to local updates +const unsubscribe = doc.subscribeLocalUpdates((updates) => { + // Send updates to other peers + otherDoc.import(updates); +}); + +// Later, unsubscribe when needed +unsubscribe(); +``` + +### Document Events + +Subscribe to all document changes. The event may be triggered by local operations, importing updates, or switching to another version. + +```ts no_run twoslash +import { LoroDoc, LoroEventBatch } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.subscribe((event: LoroEventBatch) => { + console.log("Event triggered by:", event.by); // "local" | "import" | "checkout" + console.log("Event origin:", event.origin); + + for (const e of event.events) { + console.log("Target container:", e.target); + console.log("Path:", e.path); + console.log("Changes:", e.diff); + } +}); +``` + +### Container-specific Events + +Subscribe to changes in specific containers: + +```ts no_run twoslash +import { LoroDoc, LoroEventBatch } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.subscribe((event: LoroEventBatch) => { + // Handle text-specific changes + console.log("Text changed:", event); +}); + +const list = doc.getList("list"); +list.subscribe((event: LoroEventBatch) => { + // Handle list-specific changes + console.log("List changed:", event); +}); +``` + +## Advanced Features + +### Cursor Support + +Loro provides stable cursor position tracking that remains valid across concurrent edits: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "123"); + +// Get cursor at position with side (-1, 0, or 1) +const cursor = text.getCursor(0, 0); +if (cursor) { + // Get current cursor position + const pos = doc.getCursorPos(cursor); + console.log(pos.offset); // Current position + console.log(pos.side); // Cursor side + + // Cursor position updates automatically with concurrent edits + text.insert(0, "abc"); + const newPos = doc.getCursorPos(cursor); + console.log(newPos.offset); // Position updated +} +``` + +### Change Tracking + +Track and analyze document changes: + +```ts twoslash +import { LoroDoc, OpId } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); +doc.getText("text").insert(0, "Hello"); +doc.commit(); +// Get number of changes and operations +console.log(doc.changeCount()); // Number of changes +console.log(doc.opCount()); // Number of operations + +const changes = doc.getAllChanges(); +for (const [peer, peerChanges] of changes.entries()) { + for (const change of peerChanges) { + console.log("Change:", { + peer: change.peer, + counter: change.counter, + lamport: change.lamport, + timestamp: change.timestamp, + message: change.message, + }); + } +} + +// Get specific change + +const changeId = { peer: "1", counter: 0 } as OpId; +const change = doc.getChangeAt(changeId); + +// Get operations in a change +const ops = doc.getOpsInChange(changeId); + +doc.travelChangeAncestors([changeId], (change) => { + console.log("Ancestor change:", change); + return true; // continue traversal +}); + +// Get modified containers in a change +const modifiedContainers = doc.getChangedContainersIn(changeId, 1); +``` + +### Advanced Import/Export + +Loro supports various import and export modes: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +// Export modes +const doc = new LoroDoc(); +const previousVersion = doc.version(); +doc.getText("text").insert(0, "Hello"); +const snapshot = doc.export({ mode: "snapshot" }); +const updates = doc.export({ mode: "update", from: previousVersion }); +const shallowSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.oplogFrontiers(), +}); +const rangeUpdates = doc.export({ + mode: "updates-in-range", + spans: [{ id: { peer: "1", counter: 0 }, len: 10 }], +}); + +// Import with status tracking +const status = doc.import(updates); +console.log("Successfully imported:", status.success); +console.log("Pending imports:", status.pending); + +// Batch import +const status2 = doc.importBatch([snapshot, updates]); + +// Import JSON updates +const jsonStatus = doc.importJsonUpdates({ + schema_version: 1, + start_version: new Map([["1", 0]]), + peers: ["1"], + changes: [], +}); +``` + +### Path and Value Access + +Access document content through paths: + +```ts no_run twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Get value or container by path +const value = doc.getByPath("map/key"); +const container = doc.getByPath("list"); + +// Get path to a container +const path = doc.getPathToContainer("cid:root-list:List"); + +// JSONPath support +const results = doc.JSONPath("$.list[*]"); + +// Get shallow values (container IDs instead of resolved values) +const shallowDoc = doc.getShallowValue(); +console.log(shallowDoc); // { list: 'cid:root-list:List', ... } + +// Custom JSON serialization +const json = doc.toJsonWithReplacer((key, value) => { + if (value instanceof LoroText) { + return value.toDelta(); + } + return value; +}); +``` + +### Debug and Metadata + +Access debug information and metadata: + +```ts no_run twoslash +import { setDebug, LoroDoc, decodeImportBlobMeta } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +// Enable debug info +setDebug(); +const blob = doc.export({ mode: "update" }); +// Get import blob metadata +const metadata = decodeImportBlobMeta(blob, true); +console.log({ + startTimestamp: metadata.startTimestamp, + endTimestamp: metadata.endTimestamp, + mode: metadata.mode, + changeNum: metadata.changeNum, +}); +``` + + +# FILE: pages/docs/tutorial/cursor.mdx + +--- +keywords: "cursor, crdts, multi-player, awareness, position, loro" +description: "How to use Loro to implement cursor position in real-time collaboration" +--- + +# Cursor + +Cursor is an independently storable entity, meaning it can store content separately from the Loro document. It is used to stably represent positions within structures such as Text, List, or MovableList. Cursors can be utilized to represent collaborative cursor positions, highlight ranges, or comment ranges. + +## Motivation + +Using "index" to denote cursor positions can be unstable, as positions may shift with document edits. To reliably represent a position or range within a document, it is more effective to leverage the unique ID of each item/character in a List CRDT or Text CRDT. + +## Updating Cursors + +Loro optimizes State metadata by not storing the IDs of deleted elements. This approach, while efficient, complicates tracking cursor positions since they rely on these IDs for precise locations within the document. The solution recalculates position by replaying relevant history to update stable positions accurately. To minimize the performance impact of history replay, the system updates cursor info to reference only the IDs of currently present elements, thereby reducing the need for replay. + +Each position has a "Side" information, indicating the actual cursor position is on the left, right, or directly in the center of the target ID. + +Note: In JavaScript, the offset returned when querying a Stable Position is based on the UTF-16 index. + +## Example + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const loro = new LoroDoc(); +const list = loro.getList("list"); +list.insert(0, "a"); +const pos0 = list.getCursor(0); +list.insert(1, "b"); +{ + const ans = loro.getCursorPos(pos0!); + expect(ans.offset).toEqual(0); + expect(ans.side).toEqual(0); + expect(ans.update).toBeUndefined(); +} +list.insert(0, "c"); +{ + const ans = loro.getCursorPos(pos0!); + expect(ans.offset).toEqual(1); + expect(ans.side).toEqual(0); + expect(ans.update).toBeUndefined(); +} +list.delete(1, 1); +{ + const ans = loro.getCursorPos(pos0!); + expect(ans.offset).toEqual(1); + expect(ans.side).toEqual(-1); + expect(ans.update).toBeDefined(); +} +``` + + +# FILE: pages/docs/tutorial/map.mdx + +--- +keywords: "map crdt, last write win, key value, conflict" +description: "how to use loro map crdt and show all APIs of loro map crdt." +--- + +# Map + +Loro's Map uses LWW (Last-Write-Wins) semantics. When concurrent edits conflict, it compares Lamport logic timestamps to determine the winner. + +Map slot conflicts are still resolved by LWW. If the value at a key is a child +container, regular child containers have operation-derived identities, so two +peers that create a child at the same key can create two different children. If +the child should be shared by logical key, use a mergeable child container: + +```ts no_run +const body = doc.getMap("notes").ensureMergeableText(noteId); +body.insert(0, "Hello"); +``` + +Here is how to use it: + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const docA = new LoroDoc(); +docA.setPeerId("0"); +const docB = new LoroDoc(); +docB.setPeerId("1"); + +const mapA = docA.getMap("map"); +const mapB = docB.getMap("map"); + +mapA.set("a", 1); +const textB = mapB.setContainer("a", new LoroText()); +textB.insert(0, "Hi"); + +console.log(docA.toJSON()); // OUTPUT: { map: { a: 1 } } +console.log(docB.toJSON()); // OUTPUT: { map: { a: "Hi" } } + +docA.import(docB.export({ mode: "snapshot" })); +docB.import(docA.export({ mode: "snapshot" })); + +// docB wins because it has the larger peerId, thus the larger logical timestamp +console.log(docA.toJSON()); // OUTPUT: { map: { a: "Hi" } } +console.log(docB.toJSON()); // OUTPUT: { map: { a: "Hi" } } +``` + +> **Note**: When calling `map.set(key, value)` on a LoroMap, if `map.get(key)` already returns `value`, the operation will be a no-op (no operation recorded). + + +# FILE: pages/docs/tutorial/persistence.mdx + +## Best Practices for Persisting Loro Documents + +The simplest approach is to use full snapshots for both import and export +operations. Here's how it works: + +1. Import the entire snapshot when loading the document. +2. Export a complete snapshot when saving changes. +3. Implement a debounce or throttle mechanism to trigger snapshot saves after a + certain number of edits. + +This method simplifies initial application development but has a drawback: user +edits are not immediately saved. Let's explore how to quickly save each user +edit while minimizing resource consumption. + +### Balancing Quick Saves and Resource Efficiency + +To achieve both quick saves and resource efficiency: + +- Use [`Snapshot Encoding`](./encoding#snapshot-encoding) to periodically store the entire document. +- Use [`Updates Encoding`](./encoding#updates-encoding) to export delta updates frequently (e.g., after each + keystroke or with debounce/throttle). Store these binary data in fast-write + storage like user disks or a key-value database. This ensures quick saves with + low resource cost. +- Loro validates the checksum embedded in every snapshot/update header during import, so corrupted binary payloads (for example, from bit flips in queues or object storage) are rejected before they touch your document state. +- When loading a document, import the snapshot and all related updates to get + the latest version. +- After importing, export a new snapshot to replace the old one and remove + imported updates for faster future loading. +- If your `LoroDoc` has grown large and older history can be safely recycled, + use `Shallow Snapshot Encoding` to reduce snapshot size. You can archive the + history before the shallow snapshot's start version in cold storage. + +For collaboration, the binary data generated by snapshot/updates can be +transmitted through any medium, such as WebRTC, WebSocket, or HTTP. + +The strong eventual consistency in CRDTs ensures that peers with identical sets +of operations will converge to the same document state, obviating concerns about +the order, duplication, or timing of operation delivery. + + +# FILE: pages/docs/tutorial/encoding.mdx + +--- +keywords: "crdts, schema, encoding, persist, snapshot, gc" +description: "Introduce how to encode and decode Loro documents, and how to persist data" +--- + +# Export Mode + +> Loro 1.0 has stabilized the data format and will not have any breaking +> changes. + +Loro introduces three encoding modes to meet the needs for different use cases: + +- **Updates Encoding**: Encodes all or a specified range of operations, mainly + used for delta updates of documents. +- **Snapshot Encoding**: Encodes the entire document, including both all the + operations and the current document state. +- **Shallow Snapshot Encoding**: Loro 1.0 provides a new encoding format for + discarding useless historical operations. It is a special snapshot that + encodes the most recent historical operations, the starting state of the + remaining history, and the latest state of the document. + +Different encoding formats are provided through the unified `doc.export(mode)`, +and all binary encoding formats can be imported via `doc.import(bytes)`. + +```ts no_run +export type ExportMode = + | { + mode: "update"; + from?: VersionVector; + } + | { + mode: "updates-in-range"; + spans: { + id: ID; + len: number; + }[]; + } + | { + mode: "snapshot"; + } + | { + mode: "shallow-snapshot"; + frontiers: Frontiers; + }; +``` + +## Updates Encoding + +There are two modes for updates encoding. `update` 和 `updates-in-range`. + +- `update` mode will encode all ops from the from version to the latest version + of the document. +- `updates-in-range` mode will encode all ops within the specified version + range. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); +doc2.setPeerId(2); + +doc2.getText("text").insert(0, "hello"); +const bytes = doc2.export({ mode: "update", from: doc1.version() }); // Uint8Array +// const bytes = doc2.export({ mode: "updates-in-range", spans: [{id: { peer: 2, counter: 0 }, len: 1}] }); +// These bytes can be stored in a database or transmitted over a network. +doc1.import(bytes); +console.log(doc1.toJSON()); // { text: "hello" } +``` + +Updates Encoding selectively encodes operations from a chosen initial version to +the most recent, enhancing support for real-time collaboration by focusing on +incremental changes rather than the entire document state. + +## Snapshot Encoding + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); + +doc2.getText("text").insert(0, "hello"); +const bytes = doc2.export({ mode: "snapshot" }); // Uint8Array +// These bytes can be stored in a database or transmitted over a network. +doc1.import(bytes); +console.log(doc1.toJSON()); // { text: "hello" } +``` + +Snapshot Encoding comprehensively captures a document's current state and its +historical operations. This mode can quickly obtain the latest document state. + +## Shallow Snapshot Encoding + +Loro will save all editing history to resolve conflicts and history +backtracking. However, for most scenes, most of the older history is useless, +taking up too much extra memory and requires more storage space for saving. + +Loro 1.0 provides a shallow snapshot encoding mode. You can specify the starting +historical version to be retained, and Loro will truncate all the history before +this version. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc1 = new LoroDoc(); +const doc2 = new LoroDoc(); +doc2.setPeerId(2); + +const text = doc2.getText("text"); +text.insert(0, "hello"); +const frontiers = doc2.frontiers(); +text.insert(0, "w"); +text.insert(0, "o"); +text.insert(0, "r"); +text.insert(0, "l"); +text.insert(0, "d"); +const bytes = doc2.export({ mode: "shallow-snapshot", frontiers }); // Uint8Array +// These bytes can be stored in a database or transmitted over a network. +doc1.import(bytes); +console.log(doc1.toJSON()); // { text: "hello" } +``` + +Note: When using shallow snapshots, you cannot import updates that are +concurrent to the snapshot's start version. For details, see +[shallow snapshot](/docs/advanced/shallow_snapshot). + +## Loro's Snapshot File Format + +To support lazy loading capabilities, we referenced common storage formats in +databases, introduced a simple LSM engine, and abstracted the encoding results +as simple key bytes and value bytes representations, preparing for future +integration with common key-value databases. + +### Encoding Details + +In Loro, multiple consecutive Ops of the same Container are merged into one +Change, i.e., one commit record. Each Change has additional metadata, such as +ID, Lamport, Timestamp, etc. In most scenarios, Changes are also consecutive, +and related metadata can be compressed to some extent. Therefore, we combine +multiple consecutive Changes into a ChangeBlock, which is the minimum unit for +encoding Op history. Detailed encoding content can be found in the +[documentation](https://github.com/loro-dev/loro/blob/main/crates/loro-internal/src/oplog/change_store/block_encode.rs). + +We use the first Change ID of the ChangeBlock as the query key, and the encoded +ChangeBlock as the value. This allows for quick querying of specified ID Changes +and their Ops without decoding all Changes. + +The complete State is composed of the states of all Containers starting from the +Root Container. The key is the bytes representation of the ContainerID, and the +value is composed of each Container's metadata and the bytes encoded according +to its own semantic state expression. + +These key-value pairs will be stored in our implemented simple LSM structure. + +The final exported encoding will be divided into four parts: encoding header, +oplog bytes, latest state bytes, and shallow state bytes. + +The Header consists of a 4-byte magic number, 16-byte checksum, and 2-byte +encode mode. + +The oplog bytes, latest state bytes, and shallow state bytes are all exported +using the +[LSM encoding structure](https://github.com/loro-dev/loro/blob/main/crates/kv-store/src/lib.rs). + +## Snapshot Encoding Layout + +![](../concepts/shallow-imgs/image-4.png) + + +# FILE: pages/docs/tutorial/sync.mdx + +## Sync + +Two documents with concurrent edits can be synchronized by just two message +exchanges. + +Below is an example of synchronization between two documents: + +```ts twoslash +import { LoroDoc, LoroList } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const docA = new LoroDoc(); +const docB = new LoroDoc(); +const listA: LoroList = docA.getList("list"); +listA.insert(0, "A"); +listA.insert(1, "B"); +listA.insert(2, "C"); +// B import the ops from A +const data: Uint8Array = docA.export({ mode: "update" }); +// The data can be sent to B through the network +docB.import(data); +expect(docB.toJSON()).toStrictEqual({ + list: ["A", "B", "C"], +}); + +const listB: LoroList = docB.getList("list"); +listB.delete(1, 1); + +// `doc.export({mode: "update", from: version})` can encode all the ops from the version to the latest version +// `version` is the version vector of another document +const missingOps = docB.export({ + mode: "update", + from: docA.oplogVersion(), +}); +docA.import(missingOps); + +expect(docA.toJSON()).toStrictEqual({ + list: ["A", "C"], +}); +expect(docA.toJSON()).toStrictEqual(docB.toJSON()); +``` + +## Real-time Collaboration + +Due to CRDT properties, document consistency is guaranteed when peers receive the same updates, regardless of order or duplicates. + +Consistency does not remove the need to choose the right child container +identity. If two offline peers create regular child containers under the same +Map key, sync will converge to one visible Map value and the other child can be +hidden by the Map conflict rule. For dynamic child containers that should be +shared by logical key, use `ensureMergeable*` when modeling the document. + +### Sync Strategies + +1. **First Sync** (Initial synchronization between peers): + - New peers can exchange their [Version Vectors](/docs/tutorial/version) to determine missing updates + - Use `doc.export({ mode: "update", from: versionVector })` to get updates since the peer's last known state. + You may as well send the whole history by `doc.export({ mode: "update" })` as shown in the example above. + - Example shows basic first sync scenario + +2. **Realtime Sync** (Continuous updates): + - Subscribe to local updates + - Broadcast updates directly to all other peers + - No need for version comparison after initial sync + - As long as updates reach all peers, consistency is maintained + +### Example + +Here's how two peers can establish realtime sync when one comes online with offline changes: + +1. Both peers exchange their version information +2. Each peer shares their missing updates: + - `doc2` gets updates it's missing from `doc1` + - `doc1` gets updates it's missing from `doc2` +3. Both peers establish realtime sync to stay connected + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc1 = new LoroDoc(); +doc1.getText("text").insert(0, "Hello"); +// Peer2 joins the network +const doc2 = new LoroDoc(); +// ... doc2 may import its local snapshot + +// 1. Exchange version information +const peer2Version = doc2.oplogVersion(); +const peer1Version = doc1.oplogVersion(); + +// 2. Request missing updates from existing peers +const missingOps = doc1.export({ + mode: "update", + from: peer2Version, +}); +doc2.import(missingOps); +const missingOps2 = doc2.export({ + mode: "update", + from: peer1Version, +}); +doc1.import(missingOps2); + +// 3. Establish realtime sync +doc2.subscribeLocalUpdates((update) => { + // websocket.send(update); +}); +doc1.subscribeLocalUpdates((update) => { + // websocket.send(update); +}); + +// Now both peers are in sync and can collaborate +``` + +## Understanding the `import()` Return Value + +The `import()` method in Loro's JavaScript/WASM binding returns an object that provides feedback on the import operation. This object, let's call it `ImportStatusJS`, has the following structure: + +```ts no_run twoslash +interface ImportStatusJS { + success: PeerVersionRange; + pending?: PeerVersionRange; // Optional: only present if there are pending operations +} + +interface PeerVersionRange { + [peerId: string]: { + start: number; // Start counter (inclusive) + end: number; // End counter (exclusive) + }; +} +``` + +### Fields Explained: + +1. **`success`** (Object, `PeerVersionRange`) + - **Description**: This field is always present and details the ranges of operations (changes) that were successfully imported and applied to the Loro document. + - **Structure**: It's an object where: + - Each **key** is a `string` representing a `PeerID` (the unique identifier of a collaborator or a source of changes). + - Each **value** is an object `{ start: number, end: number }` defining a continuous range of operation counters for that specific peer. + - `start`: The starting counter of the successfully imported range (inclusive). + - `end`: The ending counter of the successfully imported range (exclusive). This means operations from `start` up to, but not including, `end` were processed. + - **Purpose**: Helps understand which parts of the provided update data have been integrated into the local document's state. + - **Example**: + ```javascript + // Assuming importResult is the return value of doc.import(bytes) + console.log(importResult.success); + // Example output: + // { + // "clientA_peerId": { "start": 0, "end": 50 }, + // "server_peerId": { "start": 120, "end": 150 } + // } + // This means operations from clientA (counters 0-49) and + // operations from server (counters 120-149) were successfully imported. + ``` + +2. **`pending`** (Object, `PeerVersionRange`, optional) + - **Description**: This field is only present if some operations from the imported data could not be applied because they depend on other operations that Loro has not seen yet (i.e., their causal dependencies are missing). It details these "pending" operation ranges. + - **Structure**: Identical to the `success` field. An object mapping `PeerID` strings to `{ start: number, end: number }` counter ranges. + - **Purpose**: Informs the application that certain changes are known but are "on hold" awaiting their prerequisites. To apply these pending changes, the missing prerequisite operations must be imported first. This is crucial for maintaining data consistency in collaborative scenarios. + - **Example**: + ```javascript + // Assuming importResult is the return value of doc.import(bytes) + if (importResult.pending) { + console.log(importResult.pending); + // Example output: + // { + // "clientA_peerId": { "start": 50, "end": 60 }, + // "clientB_peerId": { "start": 10, "end": 25 } + // } + // This means operations from clientA (counters 50-59) and + // operations from clientB (counters 10-24) are pending due to missing dependencies. + } + ``` + +### How to Use This Information: + +- Check the `success` field to confirm which updates were applied. +- If the `pending` field exists and is not empty, it signals that further updates (dependencies) are required to fully integrate all known changes. Your application might need to fetch or request these missing updates from other peers or a central server. + + +# FILE: pages/docs/tutorial/list.mdx + +--- +keywords: "list crdt, movable list, move element, cursor, awareness" +description: "how to use loro list and movable list crdt and show all APIs of loro list and movable crdt." +--- + +# List and Movable List + +Loro supports two types of lists: `List` and `MovableList`. The `List` is a +standard list that supports Insert and Delete operations. In contrast, the +`MovableList` supports additional Set and Move operations. + +Using a combination of insert and delete operations, one can simulate set and +move operations on a `List`. However, this approach fails in concurrent editing +scenarios. For example, if the same element is set or moved concurrently, the +simulation would result in the deletion of the original element and the +insertion of two new elements, which does not meet expectations. + +The `MovableList` addresses these issues by ensuring that set and move +operations do not lead to such problems, though it incurs additional overheads. +Specifically, when performing only insertions/deletions, the `MovableList` is +approximately 80% slower in encode/decode and consumes about 50% more memory +compared to the `List`. + +Both `List` and `MovableList` utilize the +[_Fugue_](https://arxiv.org/abs/2305.00583) to achieve _maximal +non-interleaving_. Additionally, `MovableList` uses the algorithm from +[_Moving Elements in List CRDTs_](https://martin.kleppmann.com/2020/04/27/papoc-list-move.html) +to implement the move operation. + +## Basic Usage + +### List + +```ts twoslash +import { LoroDoc, Loro } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const docA = new LoroDoc(); +docA.setPeerId("1"); +const listA = docA.getList("list"); +listA.push(0); +listA.push(1); +listA.push(2); +const bytes: Uint8Array = docA.export({ mode: "snapshot" }); + +const docB = Loro.fromSnapshot(bytes); +docB.setPeerId("2"); +const listB = docB.getList("list"); +{ + // Concurrently docA and docB update element at index 2 + // docA updates it to 8 + // docB updates it to 9 + // docA.toJSON() should return { list: [0, 1, 8] } + // docB.toJSON() should return { list: [0, 1, 9] } + listB.delete(2, 1); + listB.insert(2, 9); + expect(docB.toJSON()).toStrictEqual({ list: [0, 1, 9] }); + listA.delete(2, 1); + listA.insert(2, 8); + expect(docA.toJSON()).toStrictEqual({ list: [0, 1, 8] }); +} + +{ + // Merge docA and docB + docA.import(docB.export({ mode: "update", from: docA.version() })); + docB.import(docA.export({ mode: "update", from: docB.version() })); +} + +expect(docA.toJSON()).toStrictEqual({ list: [0, 1, 8, 9] }); +expect(docB.toJSON()).toStrictEqual({ list: [0, 1, 8, 9] }); +``` + +### MovableList + +```ts twoslash +import { LoroDoc, Loro } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const docA = new LoroDoc(); +docA.setPeerId("1"); +const listA = docA.getMovableList("list"); +listA.push(0); +listA.push(1); +listA.push(2); +const bytes: Uint8Array = docA.export({ mode: "snapshot" }); + +const docB = Loro.fromSnapshot(bytes); +docB.setPeerId("2"); +const listB = docB.getMovableList("list"); +{ + // Concurrently docA and docB update element at index 2 + // docA updates it to 8 + // docB updates it to 9 + // docA.toJSON() should return { list: [0, 1, 8] } + // docB.toJSON() should return { list: [0, 1, 9] } + listA.set(2, 8); + expect(docA.toJSON()).toStrictEqual({ list: [0, 1, 8] }); + listB.set(2, 9); + expect(docB.toJSON()).toStrictEqual({ list: [0, 1, 9] }); +} + +{ + // Merge docA and docB + docA.import(docB.export({ mode: "update", from: docA.version() })); + docB.import(docA.export({ mode: "update", from: docB.version() })); +} + +// Converge to [0, 1, 9] because docB has larger peerId thus larger logical time +expect(docA.toJSON()).toStrictEqual({ list: [0, 1, 9] }); +expect(docB.toJSON()).toStrictEqual({ list: [0, 1, 9] }); + +{ + // Concurrently docA and docB move element at index 0 + // docA moves it to 2 + // docB moves it to 1 + // docA.toJSON() should return { list: [1, 9, 0] } + // docB.toJSON() should return { list: [1, 0, 9] } + listA.move(0, 2); + listB.move(0, 1); + expect(docA.toJSON()).toStrictEqual({ list: [1, 9, 0] }); + expect(docB.toJSON()).toStrictEqual({ list: [1, 0, 9] }); +} + +{ + // Merge docA and docB + docA.import(docB.export({ mode: "update", from: docA.version() })); + docB.import(docA.export({ mode: "update", from: docB.version() })); +} + +// Converge to [1, 0, 9] because docB has larger peerId thus larger logical time +expect(docA.toJSON()).toStrictEqual({ list: [1, 0, 9] }); +expect(docB.toJSON()).toStrictEqual({ list: [1, 0, 9] }); +``` + +## Using Cursor on List + +The Cursor on a List changes with the list's modifications. If new elements are +inserted in front of it, it moves to the right. If elements in front are +deleted, it moves to the left. If elements are inserted or deleted behind it, it +remains stationary. + +If you use a List to represent paragraphs in an article, you can use a Cursor to +stably represent the selection range on the paragraph. + +```ts twoslash +import { + Cursor, + LoroDoc, + LoroList, + LoroMovableList, + LoroText, +} from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); +const list = doc.getList("list"); +list.push("Hello"); +list.push("World"); +const cursor = list.getCursor(1)!; +console.log(cursor.pos()); // OUTPUT: { peer: "1", counter: 1 } + +const encodedCursor: Uint8Array = cursor.encode(); +const exported: Uint8Array = doc.export({ mode: "snapshot" }); + +// Sending the exported snapshot and the encoded cursor to peer 2 +// Peer 2 will decode the cursor and get the position of the cursor in the list +// Peer 2 will then insert "Hello" at the beginning of the list + +const docB = new LoroDoc(); +docB.setPeerId("2"); +const listB = docB.getList("list"); +docB.import(exported); +listB.insert(0, "Foo"); +console.log(docB.toJSON()); // OUTPUT: { list: ["Foo", "Hello", "World"] } +const cursorB = Cursor.decode(encodedCursor); +{ + // The cursor position is shifted to the right by 1 + const pos = docB.getCursorPos(cursorB); + console.log(pos.offset); // OUTPUT: 2 +} +listB.insert(1, "Bar"); +console.log(docB.toJSON()); // OUTPUT: { list: ["Foo", "Bar", "Hello", "World"] } +{ + // The cursor position is shifted to the right by 1 + const pos = docB.getCursorPos(cursorB); + console.log(pos.offset); // OUTPUT: 3 +} +listB.delete(3, 1); +console.log(docB.toJSON()); // OUTPUT: { list: ["Foo", "Bar", "Hello"] } +{ + // The position the cursor points to is now deleted, + // but it should still get the position + const pos = docB.getCursorPos(cursorB); + console.log(pos.offset); // OUTPUT: 3 + + // It will also offer an update on the cursor position. + // + // Because the old cursor position is deleted, `doc.getCursorPos()` will slow down over time. + // Internally, it needs to traverse the related history to find the correct position for a deleted + // cursor position. + // + // After refreshing the cursor, the performance of `doc.getCursorPos()` will improve. + console.log(pos.update); // OUTPUT: { peer: "2", counter: 1 } + const newCursor: Cursor = pos.update!; + + // The new cursor position is undefined because the cursor is at the end of the list + console.log(newCursor.pos()); // OUTPUT: undefined + // The side is 1 because the cursor is at the right end of the list + console.log(newCursor.side()); // OUTPUT: 1 + + const newPos = docB.getCursorPos(newCursor); + // The offset doesn't change + console.log(newPos.offset); // OUTPUT: 3 + // The update is undefined because the cursor no longer needs to be updated + console.log(newPos.update); // OUTPUT: undefined +} +``` + + +# FILE: pages/docs/tutorial/tree.mdx + +--- +keywords: "tree crdt, move operation, fractional index, hierarchical, tree" +description: "how to use loro tree crdt and show all APIs of loro tree crdt." +--- + +# Tree + +When it comes to utilizing hierarchical data structures and even performing operations across different levels of data, tree structures are of paramount importance. Loro implements the concept of a movable tree CRDT, based on the algorithm created by Kleppmann et al. in **_[A highly-available move operation for replicated trees](https://martin.kleppmann.com/2021/10/07/crdt-tree-move-operation.html)_**. + +In addition to this, Loro further employs **Fractional Index** (an algorithm used in distributed systems to maintain the order of sequences) to sort each child node, ensuring that sibling nodes maintain an orderly sequence. As a result, Loro can provide APIs such as `moveAfter()` and `moveBefore()`. This is particularly useful when a custom-ordered hierarchical view is required. + +## Node Data + +Loro associates a [Map](https://www.loro.dev/docs/tutorial/map) with each tree node, serving as a data container for the node, allowing you to nest any data structure supported by Loro. + +> Note: The associated Map Container is considered a child of the Tree Container within the Loro documentation hierarchy. + +You can get the associated map by `.data` of the `LoroTreeNode` so you can invoke `set` or `setContainer` on the map to add some data or sub-containers to the tree node. For example: + +```ts twoslash +import { LoroDoc, LoroTree, LoroTreeNode, LoroMap, LoroList } from "loro-crdt"; +// ---cut--- +let doc = new LoroDoc(); +let tree: LoroTree = doc.getTree("tree"); +let node: LoroTreeNode = tree.createNode(); +node.data.set("key", "value"); +node.data.setContainer("list", new LoroList()); +``` + +`node.data` is a Map. If the child container under `node.data` may be lazily +created by multiple peers at the same key, use a mergeable child such as +`node.data.ensureMergeableList("list")` instead of `setContainer`. + +## Ordered Tree Nodes + +In certain scenarios such as graphic design or file systems, where sibling nodes may also require a sequential relationship, we have introduced `Fractional Index` in Loro to support this capability. You can read more about `Fractional Index` in the [Figma blog](https://www.figma.com/blog/realtime-editing-of-ordered-sequences). In simple terms, `Fractional Index` assigns a sortable value to each object, and if a new insertion occurs between two objects, the `Fractional Index` of the new object will be between the left and right values. The rust-based `Fractional Index` [implementation of Drifting-in-Space](https://github.com/drifting-in-space/fractional_index) has good design and minimal document size growth in most cases. We forked it and made a simple extension for use in Loro. + +Whenever a new tree node is created or a node is moved to a new position, Loro will generate a `Fractional Index` for the node based on its position to sort among sibling nodes. In collaborative environments, conflicts with `Fractional Indexes` can arise, such as different nodes having the same `Fractional Index` or the situation where the calculation of a new node's position results in the same `Fractional Index` on both sides. We will discuss the corresponding conflict resolution methods in detail in [the blog post](https://www.loro.dev/blog/movable-tree). + +You should call `enable_fractional_index(jitter: number)` to enable `Fractional Index`. + +> Note: Fractional Index has an interleaving issue, but we believe this is acceptable for tree structures. + +## Events + +There are three types of events in the tree structure: `Create`, `Move`, and `Delete`, as follows: + +```ts no_run twoslash +import { TreeID } from "loro-crdt"; +// ---cut--- +export type TreeDiffItem = + | { + target: TreeID; + action: "create"; + parent: TreeID | undefined; + index: number; + fractionalIndex: string; + } + | { + target: TreeID; + action: "delete"; + oldParent: TreeID | undefined; + oldIndex: number; + } + | { + target: TreeID; + action: "move"; + parent: TreeID | undefined; + index: number; + fractionalIndex: string; + oldParent: TreeID | undefined; + oldIndex: number; + }; +``` + +Here, `index` and `fractionalIndex` represent the node's index and the hex string representation of the `Fractional Index`, respectively. Loro will emit events in the order of causality. The deletion event signifies that the target node, along with the entire subtree rooted at the target node, has been deleted. Deletion events for the child nodes are not emitted. + +### Events of node's data + +Since the data of the tree nodes is represented using `MapContainer`, each `MapContainer` associated with a tree node is a child of the `TreeContainer` in the document. If you modify the data of a tree node, you will receive an event from the `MapContainer`. However, the event path contains a element of `TreeID` to indicate which node's data has been altered. + +## Retrieving All Nodes + +There are multiple ways to retrieve all nodes from a `LoroTree`: + +1. `nodes()`: Retrieves all `LoroTreeNode` instances from the current `LoroTree`, but this function does not guarantee the order of the nodes. +2. `roots()`: Retrieves all root nodes from the current `LoroTree`, with the root nodes being ordered. Subsequently, the `children()` method of `LoroTreeNode` can be used to perform a hierarchical traversal. +3. `toArray()`: Retrieves all node information in a hierarchical traversal order, where each node's data structure is as follows: + + ```ts no_run twoslash + import { TreeID, LoroMap } from "loro-crdt"; + // ---cut--- + { + id: TreeID; + parent: TreeID | undefined; + index: number; + fractionalIndex: string; + meta: LoroMap; + } + ``` + +4. `toJSON()`: The JSON representation of `toArray()`, where each node's `meta` is also recursively parsed into JSON format. + +## Basic Usage + +### Example + +```ts no_run twoslash +import { LoroDoc, LoroTree, LoroTreeNode, LoroMap } from "loro-crdt"; +// ---cut--- +let doc = new LoroDoc(); +let tree: LoroTree = doc.getTree("tree"); +let root: LoroTreeNode = tree.createNode(); +// By default, append to the end of the parent node's children list +let node = root.createNode(); +// Specify the child's position +let node2 = root.createNode(0); +// Move the node to become a child of another node +node.move(node2); +// Specify the child's position within the new parent +node.move(node2, 0); +// Move the node to become the root node +node.move(); +// Move the node to be positioned after another node +node.moveAfter(node2); +// Move the node to be positioned before another node +node.moveBefore(node2); +// Retrieve the index of the node within its parent's children +let index = node.index(); +// Get the fractional index of the node +let fractionalIndex = node.fractionalIndex(); +// Access the associated data map container +let nodeData: LoroMap = node.data; +``` + + +# FILE: pages/docs/tutorial/time_travel.mdx + +--- +keywords: "crdt, time travel, history, checkout, version control" +description: "time travel in Loro" +--- + +# How to Use Time Travel in Loro + +In Loro, you can call `doc.checkout(frontiers)` to jump to the version specified +by the +frontiers([Learn more about frontiers](/docs/advanced/version_deep_dive#frontiers)). + +Note that using `doc.checkout(frontiers)` to jump to a specific version places +the document in a detached state, preventing further edits. To learn more, see +[_Attached/Detached Status_](/docs/advanced/doc_state_and_oplog#attacheddetached-status). +To continue editing, reattach the document to the latest version using +`doc.attach()`. This design is temporary and will be phased out once we have a +more refined version control API in place. + +## Read-only Time Travel + +Below we demonstrate how to implement simple, read-only time-travel. You could, +for example, combine this with a slider in a UI to allow users to view the document +over time. + +### Enable Timestamps + +Before this example will work, it is important that the edits made to the document +have had [timestamp storage](/docs/advanced/timestamp) enabled: + +```ts no_run +doc.setRecordTimestamp(true); +``` + +This makes sure that all changes to the document will have a timestamp added to it. +We will use this timestamp to sort changes so that the ordering will match user +intuition. + +### Implementing Time Travel + +The first step is to load our document. Here we assume that you have a snapshot from your database +or API. + +```ts no_run +// Get the snapshot for your doc from your database / API +let snapshot = fetchSnapshot(); + +// Import into a new document +const doc = new LoroDoc(); +doc.import(snapshot); +``` + +Next we must collect and sort the timestamps for every change in the document. We want uesrs to be +able to drag a slider to select a timestamp out of this list. + +```ts no_run +// Collect all changes from the document +const changes = doc.getAllChanges(); + +// Get the timestamps for all changes +const timestamps = Array.from( + new Set( + [...changes.values()] + .flat() // Flatten changes from all peers into one list + .map((x) => x.timestamp) // Get the timestamp from each peer + .filter((x) => !!x), + ), +); + +// Sort the timestamps +timestamps.sort((a, b) => a - b); +``` + +Next we need to make a helper function that will return a list of +[Frontiers](/docs/advanced/version_deep_dive#frontiers) for any timestamp. + +For each peer that has edited a document, there is a list of changes by that peer. Each change has a +`counter`, and a `length`. That `counter` is like an always incrementing version number for the +changes made by that peer. + +A change's `counter` is the starting point of the change, and the `length` indicates how much the +change incremented the counter before the end of the change. + +The frontiers are the list of counters that we want to checkout from each peer. Since we are going +for a timeline view, we want to get the highest counter that we know happned before our timestamp +for each peer. + +Here we make a helper function to do that. + +```ts +const getFrontiersForTimestamp = ( + changes: Map, + ts: number, +): { peer: string; counter: number }[] => { + const frontiers = [] as { peer: string; counter: number }[]; + + // Record the highest counter for each peer where it's change is not later than + // our target timestamp. + changes.forEach((changes, peer) => { + let counter = -1; + for (const change of changes) { + if (change.timestamp <= ts) { + counter = Math.max(counter, change.counter + change.length - 1); + } + } + if (counter > -1) { + frontiers.push({ counter, peer }); + } + }); + return frontiers; +}; +``` + +Finally, all we can get the index from our slider, get the timestamp from our list, and then +checkout the calculated frontiers. + +```ts no_run +let sliderIdx = 3; +const timestamp = timestamps[sliderIdx - 1]; +const frontiers = getFrontiersForTimestamp(changes, timestamp); + +doc.checkout(frontiers); +``` + +## Time Travel With Editing + +Below is a more complete example demonstrating Time Travel functionality with a node editor. + + + + +# FILE: pages/docs/tutorial/composition.mdx + +--- +keywords: "crdts, json, data model, document state, semantics" +description: "Everyone can effectively model the states and the updates of documents that conform to the JSON schema." +--- + +# Composing CRDTs + +In Loro, you can build complex data structures using basic CRDTs such as List, MovableList, Map and Tree. These containers can include sub-containers, which in turn can contain more sub-containers, allowing for the composition of intricate data structures. + +It's important to note that documents in Loro must adhere to a tree structure. This means that while a parent can have multiple children, each child is restricted to only one parent. Therefore, the document forms a tree rather than a graph (like a DAG). + +By leveraging these fundamental CRDTs, you can effectively model the states and the updates of documents that conform to the JSON schema. + +```ts twoslash +import { LoroDoc, LoroList, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +const map = doc.getMap("map"); +let callTimes = 0; +// Events from a child are propagated to all ancestor nodes. +map.subscribe((event) => { + console.log(event); + callTimes++; +}); + +// Create a sub container for map +// { map: { list: [] } } +const list = map.setContainer("list", new LoroList()); +list.push(0); +list.push(1); + +// Create a sub container for list +// { map: { list: [0, 1, LoroText] } } +const text = list.insertContainer(2, new LoroText()); +expect(doc.toJSON()).toStrictEqual({ map: { list: [0, 1, ""] } }); +{ + // Commit will trigger the event, because list is a sub container of map + doc.commit(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(callTimes).toBe(1); +} + +text.insert(0, "Hello, "); +text.insert(7, "World!"); +expect(doc.toJSON()).toStrictEqual({ map: { list: [0, 1, "Hello, World!"] } }); +{ + // Commit will trigger the event, because text is a descendant of map + doc.commit(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(callTimes).toBe(2); +} +``` + +`setContainer` creates a regular child container. This is fine for fixed +schemas, or when each creation should produce a distinct child object. If +multiple peers may lazily create the same child under the same Map key, use a +mergeable child container instead: + +```ts no_run +const sharedList = map.ensureMergeableList("list"); +``` + + +# FILE: pages/docs/tutorial/text.mdx + +--- +keywords: "text crdt, richtext, richtext editor" +description: "how to use loro richtext crdt and show all APIs of loro text crdt." +--- + +# Text + +Loro supports both plain text and rich text. When rich text features (like mark +and unmark) are not used, the text container operates as plain text without any +rich text overhead, making it efficient for simple text operations. + +LoroText offers excellent performance, particularly when handling large strings. +It significantly outperforms native JavaScript string operations due to its +internal B-tree structure. All basic operations like insert and delete have +O(log N) time complexity, making it highly efficient even when working with +documents containing several million characters. + +> To learn how rich text CRDT in Loro works under the hood, please refer to our +> blog: [Introduction to Loro's Rich Text CRDT](/blog/loro-richtext). + +LoroText also supports stable cursor and selection positions. Using the Cursor API (`text.getCursor`, `doc.getCursorPos`, `Side`), you can represent caret/selection endpoints that remain valid across concurrent edits—similar to Yjs's `RelativePosition`. See the [Cursor](/docs/tutorial/cursor) guide for details. + +## Editor Bindings + +Loro provides official bindings for popular editors to make it easier to integrate Loro's CRDT capabilities: + +### ProseMirror Binding + +The [loro-prosemirror](https://github.com/loro-dev/loro-prosemirror) package provides seamless integration between Loro and ProseMirror, a powerful rich text editor framework. It includes: + +- Document state synchronization with rich text support +- Cursor awareness and synchronization +- Undo/Redo support in collaborative editing + +The ProseMirror binding can also be used with [Tiptap](https://tiptap.dev/), a popular rich text editor built on top of ProseMirror. This means you can easily add collaborative editing capabilities to your Tiptap-based applications. + +```ts no_run +import { + CursorAwareness, + LoroCursorPlugin, + LoroSyncPlugin, + LoroUndoPlugin, + undo, + redo, +} from "loro-prosemirror"; +import { LoroDoc } from "loro-crdt"; +import { EditorView } from "prosemirror-view"; +import { EditorState } from "prosemirror-state"; +import { keymap } from "prosemirror-keymap"; + +const doc = new LoroDoc(); +const awareness = new CursorAwareness(doc.peerIdStr); +const plugins = [ + ...pmPlugins, + LoroSyncPlugin({ doc }), + LoroUndoPlugin({ doc }), + keymap({ + "Mod-z": undo, + "Mod-y": redo, + "Mod-Shift-z": redo, + }), + LoroCursorPlugin(awareness, {}), +]; +const editor = new EditorView(editorDom, { + state: EditorState.create({ doc, plugins }), +}); +``` + +### CodeMirror Binding + +The [loro-codemirror](https://github.com/loro-dev/loro-codemirror) package provides integration between Loro and CodeMirror 6, a versatile code editor. It supports: + +- Document state synchronization +- Cursor awareness +- Undo/Redo functionality + +```ts no_run +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { LoroExtensions } from "loro-codemirror"; +import { Awareness, LoroDoc, UndoManager } from "loro-crdt"; + +const doc = new LoroDoc(); +const awareness = new Awareness(doc.peerIdStr); +const undoManager = new UndoManager(doc, {}); + +new EditorView({ + state: EditorState.create({ + extensions: [ + // ... other extensions + LoroExtensions( + doc, + { + awareness: awareness, + user: { name: "Bob", colorClassName: "user1" }, + }, + undoManager, + ), + ], + }), + parent: document.querySelector("#editor")!, +}); +``` + +## LoroText vs String + +It's important to understand that LoroText is very different from using a regular string type. So the following code has different merge results: + +Using `LoroText`: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello"); +const doc2 = new LoroDoc(); +doc2.setPeerId("1"); +doc2.getText("text").insert(0, "World"); +doc.import(doc2.export({ mode: "update" })); +console.log(doc.getText("text").toString()); // "HelloWorld" +``` + +Using `String`: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getMap("map").set("text", "Hello"); +const doc2 = new LoroDoc(); +doc2.setPeerId("1"); +doc2.getMap("map").set("text", "World"); +doc.import(doc2.export({ mode: "update" })); +console.log(doc.getMap("map").get("text")); // "World" +``` + +### Merge Semantics + +Unlike LoroMap which uses Last-Write-Wins (LWW) semantics, LoroText is designed to preserve edits. Here's how they differ: + +When user A and user B make concurrent edits to the same text: + +- LoroText will merge both users' edits in sequence, preserving both changes +- LoroMap will use LWW semantics, keeping only one user's changes + +### When to Use String in LoroMap + +There are specific scenarios where using a string in LoroMap (with LWW semantics) might be more appropriate than using LoroText: + +- **URLs**: When dealing with hyperlinks, automatic merging could result in invalid URLs. In this case, it's better to use LoroMap's LWW semantics to ensure the URL remains valid. +- **Hash String**: When handling hash string, LWW semantics are more appropriate to maintain data accuracy and consistency. + +## Rich Text Config + +To use rich text in Loro, you need to specify the expanding behaviors for each +format first. When we insert new text at the format boundaries, they define +whether the inserted text should inherit the format. + +There are four kinds of expansion behaviors: + +- `after`(default): when inserting text right after the given range, the mark + will be expanded to include the inserted text +- `before`: when inserting text right before the given range, the mark will be + expanded to include the inserted text +- `none`: the mark will not be expanded to include the inserted text at the + boundaries +- `both`: when inserting text either right before or right after the given + range, the mark will be expanded to include the inserted text + +### Example + +```ts twoslash +import { LoroDoc, Delta } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + link: { expand: "before" }, +}); +const text = doc.getText("text"); +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello", + attributes: { + bold: true, + }, + }, + { + insert: " World!", + }, +] as Delta[]); + +// " Test" will inherit the bold style because `bold` is configured to expand forward +text.insert(5, " Test"); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello Test", + attributes: { + bold: true, + }, + }, + { + insert: " World!", + }, +] as Delta[]); +``` + +## Methods + +### `insert(pos: number, s: string)` + +Insert text at the given pos. + +### `delete(pos: number, len: number)` + +Delete text at the given range. + +### `slice(start: number, end: number): string` + +Get a string slice. + +### `sliceDelta(start: number, end: number): Delta[]` + +Get a Quill-style Delta slice for the given UTF-16 range. Use +`sliceDeltaUtf8` to slice by UTF-8 byte offsets instead. + +### `sliceDeltaUtf8(start: number, end: number): Delta[]` + +Get a Quill-style Delta slice for the given UTF-8 byte range. + +### `toString(): string` + +Get the plain text value. + +### `charAt(pos: number): char` + +Get the character at the given position. + +### `splice(pos: number, len: number, text: string): string` + +Delete and return the string at the given range and insert a string at the same +position. + +### `length: number` + +Get the length of text + +### `getCursor(pos: number, side?: Side): Cursor | undefined` + +Create a stable position handle for the given logical index. This is equivalent in purpose to Yjs's `RelativePosition` and is designed to stay valid across concurrent inserts/deletes. + +- A cursor can represent a caret or one end of a selection. Store two cursors (anchor/head) to represent a selection. +- Use `doc.getCursorPos(cursor)` to resolve the current absolute offset and side. It may also return an updated cursor you should persist to minimize future replays. +- Offsets for Text are UTF-16 indices for the WASM binding. +- `side` controls whether the cursor sits to the left (-1), center (0), or right (1) of the target ID at boundaries. It affects how the cursor behaves when text is inserted at its position. + +See the dedicated guide: [Cursor](/docs/tutorial/cursor). + +Example (caret): + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "123"); + +// Create a stable cursor at the beginning +const cur = text.getCursor(0, 0); +{ + const pos = doc.getCursorPos(cur!); + expect(pos.offset).toBe(0); +} + +// Insert before the cursor. The cursor now moves forward +text.insert(0, "1"); +{ + const pos = doc.getCursorPos(cur!); + expect(pos.offset).toBe(1); +} +``` + +Example (selection via two cursors): + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.update("Hello World"); + +// Anchor at index 0 (left side), head at index 5 (right side) +const anchor = text.getCursor(0, -1)!; +const head = text.getCursor(5, 1)!; + +// Resolve to absolute positions when needed +const a = doc.getCursorPos(anchor); +const h = doc.getCursorPos(head); +// selection = [a.offset, h.offset) +``` + +Persisting and restoring a cursor: + +```ts twoslash +import { Cursor, LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.update("Hi"); +const cur = text.getCursor(1, 0)!; + +// Serialize and share +const bytes = cur.encode(); + +// Later on another peer +const restored = Cursor.decode(bytes); +const pos = doc.getCursorPos(restored); +``` + +### `toJSON(): string` + +Returns the plain text content as a string. This method: +- Returns only the text content without any formatting marks +- Is not affected by any rich text attributes (bold, italic, links, etc.) +- Is equivalent to `toString()` for text containers + +If you need rich text information including formatting, use `toDelta()` instead. + +### `toDelta(): Delta[]` + +Get the rich text value with all formatting information. It's in +[Quill's Delta format](https://quilljs.com/docs/delta/). + +Unlike `toJSON()` which returns plain text, `toDelta()` preserves all rich text attributes like bold, italic, links, and custom marks. + +Example comparing `toJSON()` vs `toDelta()`: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.configTextStyle({ bold: { expand: "after" } }); +const text = doc.getText("text"); +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); + +// toJSON returns plain string without marks +console.log(text.toJSON()); // "Hello World!" + +// toDelta returns rich text with formatting +console.log(text.toDelta()); +// [ +// { insert: "Hello", attributes: { bold: true } }, +// { insert: " World!" } +// ] +``` + +### Slice a Delta snippet + +Use `sliceDelta(start, end)` when you only need a portion of the Delta (for example, to copy a styled snippet). It uses UTF-16 indices just like other text APIs; use `sliceDeltaUtf8` if you need to slice by UTF-8 byte offsets instead. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + comment: { expand: "none" }, +}); +const text = doc.getText("text"); + +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +text.mark({ start: 6, end: 11 }, "comment", "greeting"); + +const snippet = text.sliceDelta(1, 8); +expect(snippet).toStrictEqual([ + { insert: "ello", attributes: { bold: true } }, + { insert: " " }, + { insert: "Wo", attributes: { comment: "greeting" } }, +]); +``` + +### `mark(range: {start: number, end: number}, key: string, value: any): void` + +Mark the given range with a key-value pair. + +### `unmark(range: {start: number, end: number}, key: string): void` + +Remove key-value pairs in the given range with the given key. + +### `update(text: string)` + +Update the current text based on the provided text. + +### `applyDelta(delta: Delta[]): void` + +Change the state of this text by delta. + +If a delta item is `insert`, it should include all the attributes of the +inserted text. Loro's rich text CRDT may make the inserted text inherit some +styles when you use the `insert` method directly. However, when you use +`applyDelta` if some attributes are inherited from CRDT but not included in the +delta, they will be removed. + +Another special property of `applyDelta` is if you format an attribute for +ranges out of the text length, Loro will insert new lines to fill the gap first. +It's useful when you build the binding between Loro and rich text editors like +Quill, which might assume there is always a newline at the end of the text +implicitly. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +const text = doc.getText("text"); +doc.configTextStyle({ bold: { expand: "after" } }); + +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +const delta = text.toDelta(); +const text2 = doc.getText("text2"); +text2.applyDelta(delta); +expect(text2.toDelta()).toStrictEqual(delta); +``` + +### `subscribe(f: (event: Listener)): number` + +This method returns a number that can be used to remove the subscription. + +The text event is in `Delta[]` format. It can be used to bind the rich +text editor. It has the same type as the arg of `applyDelta`, so the following +example works: + +```ts no_run twoslash +import { LoroDoc, TextDiff } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +(async () => { + const doc1 = new LoroDoc(); + doc1.configTextStyle({ + link: { expand: "none" }, + bold: { expand: "after" }, + }); + const text1 = doc1.getText("text"); + const doc2 = new LoroDoc(); + doc2.configTextStyle({ + link: { expand: "none" }, + bold: { expand: "after" }, + }); + const text2 = doc2.getText("text"); + text1.subscribe((e) => { + for (const event of e.events) { + const d = event.diff as TextDiff; + text2.applyDelta(d.diff); + } + }); + text1.insert(0, "foo"); + text1.mark({ start: 0, end: 3 }, "link", true); + doc1.commit(); + await new Promise((r) => setTimeout(r, 1)); + expect(text2.toDelta()).toStrictEqual(text1.toDelta()); + text1.insert(3, "baz"); + doc1.commit(); + await new Promise((r) => setTimeout(r, 1)); + expect(text2.toDelta()).toStrictEqual([ + { insert: "foo", attributes: { link: true } }, + { insert: "baz" }, + ]); + expect(text2.toDelta()).toStrictEqual(text1.toDelta()); + text1.mark({ start: 2, end: 5 }, "bold", true); + doc1.commit(); + await new Promise((r) => setTimeout(r, 1)); + expect(text2.toDelta()).toStrictEqual(text1.toDelta()); +})(); +``` + + +# FILE: pages/docs/tutorial/tips.mdx + +# Tips and Tricks + +##### `LoroDoc` will be initialized with a new random PeerID each time + +
+What if I need to set the initial state? + +If your document requires an initial state, you should not edit the document to achieve this state right +after creating it with new LoroDoc(). This approach can cause problems - each time someone opens the document, +new operations with different PeerIDs would be added just to set up the initial state. + +The better approach is to initialize your document by loading the same Snapshot. This ensures all users start +from an identical baseline without generating unnecessary operations. + +
+ +--- + +##### Be careful when using `doc.setPeerId(newId)` + +When using `setPeerId`, you must avoid having two parallel peers use the same PeerId. This can cause serious consistency problems in your application. + +If you plan to reuse a PeerId across sessions, make sure that you persist the document's local state together with that PeerId and load it before applying any remote updates. Otherwise, the same `peerId + counter` combination could end up referring to two different operations, producing divergence that cannot be resolved automatically. + +
+Why + +It's because Loro determines whether an operation has already been included by checking its operation ID. Since operation IDs are composed of `PeerId + Counter`, duplicate PeerIds can easily lead to duplicate operation IDs. During synchronization, Loro might incorrectly assume certain operations have already been processed, resulting in document inconsistency across peers. + +
+ +
+How to reuse PeerIds safely + +Be careful when reusing PeerIds (this optimization is often unnecessary). You should not assign a fixed PeerId to a user, as one user might use multiple devices. Similarly, you shouldn't assign a fixed PeerId to a device, because even on a single browser, multiple tabs might open the same document simultaneously. + +If you must reuse PeerIds, you need to carefully manage your local PeerId cache with proper locking mechanisms. This would allow only one tab to "take" a specific PeerId, while other tabs use random IDs. The PeerId should be returned to the cache when no longer in use. + +In addition to locking, avoid having multiple browser tabs (or apps on different processes) use the same PeerId concurrently. Coordinating access in this way ensures that a reused PeerId never emits two different operations with the same operation ID. + +
+ +--- + +##### Root containers don't need operations to be initialized + +Root Containers are created implicitly in Loro. This means that when you call `doc.getText("text")`, no new operations appear in the LoroDoc history, and there are no operations that need to be synchronized with other peers. + +This behavior contrasts with non-root containers. For example, when you execute `doc.getMap("meta").setContainer("text", new LoroText())`, it generates an operation to insert the LoroText container into the map. + +--- + +##### When initializing child containers of LoroMap in parallel, use mergeable containers when you want one shared child. + +
+Why this happens + +This happens because parallel creation of regular child containers results in different container IDs, preventing automatic merging of their contents. When a container holds substantial data or serves as the primary storage for document content, overwriting it can lead to unintended hiding or loss of critical information. + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const map = doc.getMap("map"); + +// Parallel initialization of child containers +const docB = doc.fork(); +const textA = doc.getMap("map").setContainer("text", new LoroText()); +textA.insert(0, "A"); +const textB = docB.getMap("map").setContainer("text", new LoroText()); +textB.insert(0, "B"); + +doc.import(docB.export({ mode: "update" })); +// Result: Either { "meta": { "text": "A" } } or { "meta": { "text": "B" } } +``` + +
+ +
+Best practices for container initialization + +1. If peers may lazily create the same child under the same Map key, use a mergeable child container: + + ```ts no_run + const entries = doc.getMap("days").ensureMergeableList("2026-06-08"); + entries.insert(0, "meeting notes"); + ``` + + The same pattern is available through `ensureMergeableText`, `ensureMergeableMap`, `ensureMergeableMovableList`, `ensureMergeableTree`, and `ensureMergeableCounter`. + +2. If the child structure is fixed and known ahead of time, initialize all child containers during the map container's initialization. + +3. If the child naturally has a unique global name, a root container is also safe. You can use `doc.getMap("user." + userId)` instead of `doc.getMap("user").getOrCreateContainer(userId, new LoroMap())`. + +4. Keep using `setContainer` or `insertContainer` when each creation should produce a distinct child object, or when you are modeling replacement rather than shared initialization. +
+ +--- + +##### Use `diff` + `applyDiff` to share squash-like change sets + +For PR-style workflows, compute a diff from the base version to the new version, then apply it back. The diff is compact: operations that cancel out (e.g., insert then delete) are compressed away. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const baseDoc = new LoroDoc(); +baseDoc.getText("text").insert(0, "hello world"); + +const newDoc = baseDoc.fork(); +const text = newDoc.getText("text"); +text.insert(0, "abc"); +text.delete(0, 4); // cancels most of the insert + +const diff = newDoc.diff(baseDoc.frontiers(), newDoc.frontiers()); +baseDoc.applyDiff(diff); + +console.log(diff); +// [ +// [ +// "cid:root-text:Text", +// { type: "text", diff: [ { delete: 1 } ] } +// ] +// ] +console.log(baseDoc.toJSON()); // { text: "ello world" } +``` + +--- + +##### Use redaction to safely share document history + +There are times when users might accidentally paste sensitive information (like API keys, passwords, or personal data) into a collaborative document. When this happens, you need a way to remove just that sensitive content from the document history without compromising the rest of the document's integrity. + +
+How to safely redact sensitive content + +Loro provides a `redactJsonUpdates` function that allows you to selectively redact operations within specific version ranges. + +For example, if a user accidentally pastes a password or API key into a document: + +```ts no_run twoslash +import { LoroDoc, redactJsonUpdates } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); + +// Create some content to be redacted +const text = doc.getText("text"); +text.insert(0, "Sensitive information"); +doc.commit(); + +const map = doc.getMap("map"); +map.set("password", "secret123"); +map.set("public", "public information"); +doc.commit(); + +// Export JSON updates +const jsonUpdates = doc.exportJsonUpdates(); + +// Define version range to redact (redact the text content) +const versionRange = { + "1": [0, 21], // Redact the "Sensitive information" +}; + +// Apply redaction +const redactedJson = redactJsonUpdates(jsonUpdates, versionRange); + +// Create a new document with redacted content +const redactedDoc = new LoroDoc(); +redactedDoc.importJsonUpdates(redactedJson); + +// The text content is now redacted with replacement characters +console.log(redactedDoc.getText("text").toString()); +// Outputs: "���������������������" + +// You can also redact specific map entries +const versionRange2 = { + "1": [21, 22], // Redact the "secret123" password +}; + +const redactedJson2 = redactJsonUpdates(jsonUpdates, versionRange2); +const redactedDoc2 = new LoroDoc(); +redactedDoc2.importJsonUpdates(redactedJson2); + +console.log(redactedDoc2.getMap("map").get("password")); // null +console.log(redactedDoc2.getMap("map").get("public")); // "public information" +``` + +This approach is safer than manually editing document content because: + +1. It maintains document structure and CRDT consistency +2. It keeps key metadata like operation IDs and dependencies intact +3. It allows concurrent editing to continue working after redaction +4. It selectively redacts only specific operations, not the entire document + +The redaction process follows these rules: + +- Preserves delete, tree move, and list move operations +- Replaces text insertion content with Unicode replacement characters '�' +- Substitutes list and map insert values with null +- Maintains structure of child containers +- Replaces text mark values with null +- Preserves map keys and text annotation keys + +**Important**: Your application needs to ensure that all peers receive the redacted version, otherwise the original document with sensitive information will still exist on other peers. + +
+ +--- + +##### Use shallow snapshots to completely remove old history + +When you need to completely remove ALL history older than a certain version point, shallow snapshots provide the solution. + +
+How to remove old history with shallow snapshots + +Shallow snapshots create a new document that preserves the current state but completely eliminates all history before a specified point, similar to Git's shallow clone functionality. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); + +// Old history - will be completely removed +const text = doc.getText("text"); +text.insert(0, "This document has a long history with many edits"); +doc.commit(); +text.insert(0, "Including some potentially sensitive information. "); +doc.commit(); + +// More recent history - will be preserved +text.delete(11, 55); // Remove the middle part +text.insert(11, "with sanitized history"); +doc.commit(); + +// Create a sanitized version that removes ALL history before current point +const sanitizedSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.oplogFrontiers(), +}); + +// Create a new document from the sanitized snapshot +const sanitizedDoc = new LoroDoc(); +sanitizedDoc.import(sanitizedSnapshot); + +// The document has the final state +console.log(sanitizedDoc.getText("text").toString()); +// Outputs: "Including with sanitized history" + +// But ALL history before the snapshot point is completely removed +console.log(sanitizedDoc.isShallow()); // true +console.log(sanitizedDoc.shallowSinceFrontiers()); // Shows the starting point +``` + +This approach is useful for: + +1. Completely removing all old history that might contain various sensitive information +2. Significantly reducing document size by eliminating unnecessary history +3. Creating clean document instances after certain milestones +4. Ensuring old operations cannot be recovered or examined + +Compared to redaction: + +- Shallow snapshots completely remove all operations before a version point +- Redaction selectively replaces just specific content with placeholders + +**Important**: While both methods maintain future synchronization consistency, your application must distribute the sanitized document to all peers. Otherwise, the original document with sensitive information will still exist on other clients. + +**When to use each approach**: + +- Use **redaction** when you need to sanitize specific operations (like an accidental password paste) while preserving older history +- Use **shallow snapshots** when you want to completely eliminate all history before a certain point + +
+ +--- + +##### You can store mappings between LoroDoc's peerIds and user IDs in the document itself + +Use `doc.subscribeFirstCommitFromPeer(listener)` to associate peer information with user identities when a peer first interacts with the document. + +
+How to track peer-to-user mappings + +This functionality is essential for building user-centric features in collaborative applications. You often need bidirectional mapping between user IDs and peer IDs: + +- **Finding all edits by a user**: When you need to retrieve all document edits made by a specific user ID, you must first find all peer IDs associated with that user +- **Showing edit attribution**: When displaying which user edited a piece of text, you need to map from the peer ID (stored in the operation) back to the user ID for display + +This hook provides an ideal point to associate peer information (such as author identity) with the document. The listener is triggered on the first commit from each peer, allowing you to store user metadata within the document itself. + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribeFirstCommitFromPeer((e) => { + doc.getMap("users").set(e.peer, "user-" + e.peer); +}); +doc.getList("list").insert(0, 100); +doc.commit(); +await Promise.resolve(); +expect(doc.getMap("users").get("0")).toBe("user-0"); +``` + +This approach allows you to: + +1. Automatically track which peers have contributed to the document +2. Store user metadata (names, emails, etc.) alongside the document +3. Build features like author attribution, presence indicators, or edit history + +The mapping is stored within the document, so it automatically synchronizes across all peers and persists with the document's state. + +
+ +--- + +##### You can use https://loro.dev/llms-full.txt to prompt your AI + +When working with AI assistants or language models on Loro-related tasks, you can use these URLs to provide comprehensive context about Loro's capabilities and API: + +- `https://loro.dev/llms-full.txt` - All the documentation in one file +- `https://loro.dev/llms.txt` - An overview of Loro website + + +# FILE: pages/docs/tutorial/event.mdx + +# Event Handling in Loro + +Loro implements an event system to track changes in the document. This section +explains when events are emitted and how transactions work in Loro. + +## Event Emission Points + +Events in Loro are emitted whenever the internal document state changes. This +mechanism allows application-level derived states to automatically synchronize +with changes in the document state. + +1. **Local Operations**: For local operations (like insertions or deletions on + text), the operations are first placed in a pending state within an internal + transaction. + +2. **Transaction Commit**: When a transaction is committed, all pending + operations collectively emit their corresponding events. This transaction + commit occurs in two scenarios: + - When `LoroDoc.commit()` is explicitly called + - Automatically before an import or export operation + +Starting from `loro-crdt@1.8.0`, events are emitted synchronously during the commit cycle. If you are using an older version (`<=1.7.x`), you will still need to await a microtask for the callbacks to fire. + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.subscribe((event) => { + console.log("Event:", event); +}); + +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); +// Event has already been emitted synchronously +``` + +3. **Import**: When importing changes from a remote source using the `import()` + method, respective events are emitted. This allows the local document to + react to changes made by other peers. + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +declare const remoteChanges: Uint8Array; +// ---cut--- +const doc = new LoroDoc(); +doc.subscribe((event) => { + console.log("Event:", event); +}); + +doc.import(remoteChanges); // This immediately triggers events (v1.8+) +``` + +4. **Version Checkout**: When you switch document state to a different version + using `doc.checkout(frontiers)`, Loro emits an event to reflect this change. + As of v1.8, checkout events fire synchronously alongside the state change. + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +doc.checkout(frontiers); // This triggers events immediately (v1.8+) +``` + +## Transaction Behavior + +Transactions in Loro primarily serve to bundle related operations and emit their +events together as a cohesive unit. This is useful in several scenarios: + +1. **Related Local Operations**: When performing multiple local operations that + are logically connected, you may want them to: + - Share the same commit message + - Have the same timestamp + - Move together during undo/redo operations + +2. **Event Handling**: Applications often benefit from receiving related changes + as a single batch rather than individual updates. Transactions facilitate + this by: + - Allowing you to set an origin identifier during commit + - Including this origin value in the emitted events + - Enabling better event filtering and processing based on the origin + + + +## Triggering a Commit + +There are several ways to trigger a commit: + +1. **Explicit Commit**: Directly calling the `commit()` method on the Loro + document. + + ```ts twoslash + import { LoroDoc } from "loro-crdt"; + // ---cut--- + const doc = new LoroDoc(); + const text = doc.getText("myText"); + text.insert(0, "Hello, Loro!"); + doc.commit(); // This commits pending operations and emits events + ``` + +2. **Before Import/Export**: A commit is automatically triggered before + executing an import operation. + + ```ts twoslash + import { LoroDoc } from "loro-crdt"; + // ---cut--- + const doc1 = new LoroDoc(); + doc1.setPeerId(1); + const doc2 = new LoroDoc(); + doc2.setPeerId(2); + + // Some ops on doc1 and doc2 + doc1.getText("text").insert(0, "Alice"); + doc2.getText("text").insert(0, "Hello, Loro!"); + console.log(doc1.version().toJSON()); // Map(0) {} + console.log(doc2.version().toJSON()); // Map(0) {} + const updates = doc1.export({ mode: "snapshot" }); + doc2.import(updates); // This first commits any pending operations in doc2 + console.log(doc2.version().toJSON()); // Map(2) { "1" => 5, "2" => 12 } + console.log(doc1.version().toJSON()); // Map(2) { "1" => 5 } + ``` + +## Transactions in Loro + +It's important to note that Loro's concept of a transaction differs from +traditional database transactions: + +- Loro transactions do not have ACID properties. +- They primarily serve as event wrappers. +- There is no rollback mechanism if an operation fails. + + +# FILE: pages/docs/tutorial/version.mdx + +# Version + +In centralized environments, we can use linear version numbers to represent a version, such as incrementing a number each time or using timestamps. However, CRDTs can be used in decentralized environments, and their version representation is different. + +In Loro, you can express a document's version through a [Version Vector](/docs/concepts/version_vector) or [Frontiers](/docs/concepts/frontiers). + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.version(); // State Version vector +doc.oplogVersion(); // OpLog Version vector +doc.frontiers(); // State Frontiers +doc.oplogFrontiers(); // OpLog Frontiers +``` + +In most cases, you might only need the Version Vector, which can be used for data synchronization and version comparison. + +## Learn More + +- [Version Vector](/docs/concepts/version_vector) - Complete peer state tracking for synchronization +- [Frontiers](/docs/concepts/frontiers) - Compact version representation for checkpoints +- [Version Deep Dive](/docs/advanced/version_deep_dive) - Technical details about DAG, causality, and version implementations + + +# FILE: pages/docs/tutorial/get_started.mdx + +--- +keywords: "loro-crdt, build collaboration software, local-first, operation transform, crdts, ot" +description: "How to use Loro to build real-time or asynchronous collaboration software." +--- + +# Getting Started + +You can use Loro in your application by using: + +- [`loro-crdt`](https://www.npmjs.com/package/loro-crdt) NPM package +- [`loro`](https://crates.io/crates/loro) Rust crate +- [`loro-swift`](https://github.com/loro-dev/loro-swift) Swift package +- [`loro-py`](https://github.com/loro-dev/loro-py) Python package +- [`loro-cs`](https://github.com/sensslen/loro-cs) Community-maintained C# package +- You can also find a list of examples in + [Loro examples in Deno](https://github.com/loro-dev/loro-examples-deno). +- [`loro-go`](https://github.com/aholstenson/loro-go) Community-maintained Go package +- [`loro-go`](https://github.com/Deln0r/loro-go) Community-maintained pure-Go package (no cgo) + +You can use [Loro Inspector](/docs/advanced/inspector) to debug and visualize the state and history of Loro documents. + +The following guide will use `loro-crdt` js package as the example. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/edit/loro-basic-test?file=test%2Floro-sync.test.ts) + +## Install + +```bash +npm install loro-crdt + +# Or +pnpm install loro-crdt + +# Or +yarn add loro-crdt +``` + +If you're using `Vite`, you should add the following to your vite.config.ts: + +```ts no-run +import wasm from "vite-plugin-wasm"; +import topLevelAwait from "vite-plugin-top-level-await"; + +export default defineConfig({ + plugins: [...otherConfigures, wasm(), topLevelAwait()], +}); +``` + +
+⚠️ DOMContentLoaded Timing Issue with Vite + +When using Loro with Vite, be aware of module loading timing issues with DOM events: + +**Problem:** The following code will cause nothing to load on the screen: + +```ts no_run +import { LoroDoc } from "loro-crdt"; + +document.addEventListener("DOMContentLoaded", () => { + const doc = new LoroDoc(); + // Your code here... +}); +``` + +**Reason:** This occurs because Vite loads ES modules asynchronously, and the WASM module initialization within `loro-crdt` also happens asynchronously. When you import at the top level but execute code inside `DOMContentLoaded`, the WASM module may not be fully initialized when the event fires, causing the application to fail silently. + +**Solutions:** + +1. **Remove the event listener** (recommended for most cases): + + ```ts no_run twoslash + import { LoroDoc } from "loro-crdt"; + + const doc = new LoroDoc(); + // Your code here... + ``` + +2. **Use dynamic import inside the event listener**: + ```ts no_run twoslash + document.addEventListener("DOMContentLoaded", async () => { + const { LoroDoc } = await import("loro-crdt"); + const doc = new LoroDoc(); + // Your code here... + }); + ``` + +The dynamic import ensures the module and its WASM dependencies are fully loaded before use. + +
+ +If you're using `Next.js`, you should add the following to your next.config.js: + +```js no-run +module.exports = { + webpack: function (config) { + config.experiments = { + layers: true, + asyncWebAssembly: true, + }; + return config; + }, +}; +``` + +You can also use Loro directly in the browser via ESM imports. Here's a minimal +example: + +```html + + + + + + ESM Module Example + + + +
+ + + +``` + +## Introduction + +It is well-known that syncing data/building realtime collaborative apps is +challenging, especially when devices can be offline or part of a peer-to-peer +network. Loro simplifies this process for you. + +After you model your app state by Loro, syncing is simple: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +const docA = new LoroDoc(); +const docB = new LoroDoc(); + +//...operations on docA and docB + +// Assume docA and docB are two Loro documents in two different devices +const bytesA = docA.export({ mode: "update" }); +// send bytes to docB by any method +docB.import(bytesA); +// docB is now updated with all the changes from docA + +const bytesB = docB.export({ mode: "update" }); +// send bytes to docA by any method +docA.import(bytesB); +// docA and docB are now in sync, they have the same state +``` + +Saving your app state is also straightforward: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello world!"); +const bytes = doc.export({ mode: "snapshot" }); +// Bytes can be saved to local storage, database, or sent over the network +``` + +Snapshots and updates include a checksum in their headers, so any corruption from storage or transmission (like bit flips) is detected during import before it can affect your document. + +Loading your app state: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const bytes = new Uint8Array(); +// ---cut--- +const newDoc = new LoroDoc(); +newDoc.import(bytes); +``` + +Loro also makes it easy for you to time travel the history and add version +control to your app. [Learn more about time travel](/docs/tutorial/time_travel). + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const version = doc.frontiers(); +// ---cut--- +doc.checkout(version); // Checkout the doc to the given version +``` + +Loro is compatible with the JSON schema. If you can model your app state with +JSON, you probably can sync your app with Loro. Because we need to adhere to the +JSON schema, using a number as a key in a Map is not permitted, and cyclic links +should be avoided. + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +doc.toJSON(); // Get the JSON representation of the doc +``` + +## Entry Point: LoroDoc + +LoroDoc is the entry point for using Loro. You must create a Doc to use Map, +List, Text, and other types and to complete data synchronization. + +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text: LoroText = doc.getText("text"); +text.insert(0, "Hello world!"); +console.log(doc.toJSON()); // { "text": "Hello world!" } +``` + +## Container + +We refer to CRDT types such as `List`, `Map`, `Tree`, `MovableList`, and `Text` +as `Container`s. + +Here are their basic operations: + +```ts twoslash +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +const list: LoroList = doc.getList("list"); +list.insert(0, "A"); +list.insert(1, "B"); +list.insert(2, "C"); + +const map: LoroMap = doc.getMap("map"); +// map can only has string key +map.set("key", "value"); +expect(doc.toJSON()).toStrictEqual({ + list: ["A", "B", "C"], + map: { key: "value" }, +}); + +// delete 2 element at index 0 +list.delete(0, 2); +expect(doc.toJSON()).toStrictEqual({ + list: ["C"], + map: { key: "value" }, +}); + +// Insert a text container to the list +const text = list.insertContainer(0, new LoroText()); +text.insert(0, "Hello"); +text.insert(0, "Hi! "); + +expect(doc.toJSON()).toStrictEqual({ + list: ["Hi! Hello", "C"], + map: { key: "value" }, +}); + +// Insert a list container to the map +const list2 = map.setContainer("test", new LoroList()); +list2.insert(0, 1); +expect(doc.toJSON()).toStrictEqual({ + list: ["Hi! Hello", "C"], + map: { key: "value", test: [1] }, +}); +``` + +## Save and Load + +Loro is a pure library and does not handle network protocols or storage mechanisms. It is your responsibility to manage the storage and transmission of the binary data exported by Loro. + +To save the document, use `doc.export({mode: "snapshot"})` to get its binary +form. To open it again, use `doc.import(data)` to load this binary data. + +```ts twoslash +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello world!"); +const data = doc.export({ mode: "snapshot" }); + +const newDoc = new LoroDoc(); +newDoc.import(data); +expect(newDoc.toJSON()).toStrictEqual({ + text: "Hello world!", +}); +``` + +Exporting the entire document on each keypress is inefficient. Instead, use +`doc.export({mode: "update", from: VersionVector})` to obtain binary data for +operations since the last export. + +```ts twoslash +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello world!"); +const data = doc.export({ mode: "snapshot" }); +let lastSavedVersion = doc.version(); +doc.getText("text").insert(0, "✨"); +const update0 = doc.export({ mode: "update", from: lastSavedVersion }); +lastSavedVersion = doc.version(); +doc.getText("text").insert(0, "😶‍🌫️"); +const update1 = doc.export({ mode: "update", from: lastSavedVersion }); + +{ + /** + * You can import the snapshot and the updates to get the latest version of the document. + */ + + // import the snapshot + const newDoc = new LoroDoc(); + newDoc.import(data); + expect(newDoc.toJSON()).toStrictEqual({ + text: "Hello world!", + }); + + // import update0 + newDoc.import(update0); + expect(newDoc.toJSON()).toStrictEqual({ + text: "✨Hello world!", + }); + + // import update1 + newDoc.import(update1); + expect(newDoc.toJSON()).toStrictEqual({ + text: "😶‍🌫️✨Hello world!", + }); +} + +{ + /** + * You may also import them in a batch + */ + const newDoc = new LoroDoc(); + newDoc.importUpdateBatch([update1, update0, data]); + expect(newDoc.toJSON()).toStrictEqual({ + text: "😶‍🌫️✨Hello world!", + }); +} +``` + +If updates accumulate, exporting a new snapshot can quicken import times and +decrease the overall size of the exported data. + +You can store the binary data exported from Loro wherever you prefer. + +## Sync + +Two documents with concurrent edits can be synchronized by just two message +exchanges. + +Below is an example of synchronization between two documents: + +```ts twoslash +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const docA = new LoroDoc(); +const docB = new LoroDoc(); +const listA: LoroList = docA.getList("list"); +listA.insert(0, "A"); +listA.insert(1, "B"); +listA.insert(2, "C"); +// B import the ops from A +const data: Uint8Array = docA.export({ mode: "update" }); +// The data can be sent to B through the network +docB.import(data); +expect(docB.toJSON()).toStrictEqual({ + list: ["A", "B", "C"], +}); + +const listB: LoroList = docB.getList("list"); +listB.delete(1, 1); + +// `doc.export({mode: "update", from: version})` can encode all the ops from the version to the latest version +// `version` is the version vector of another document +const missingOps = docB.export({ + mode: "update", + from: docA.oplogVersion(), +}); +docA.import(missingOps); + +expect(docA.toJSON()).toStrictEqual({ + list: ["A", "C"], +}); +expect(docA.toJSON()).toStrictEqual(docB.toJSON()); +``` + +## Event + +You can subscribe to the event from `Container`s. + +`LoroText` and `LoroList` can receive updates in +[Quill Delta](https://quilljs.com/docs/delta/) format. + +The events will be emitted after a transaction is committed. A transaction is +committed when: + +- `doc.commit()` is called. +- `doc.export(mode)` is called. +- `doc.import(data)` is called. +- `doc.checkout(version)` is called. + +Below is an example of rich text event: + +```ts twoslash +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +// The code is from https://github.com/loro-dev/loro-examples-deno +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello world!"); +doc.commit(); +let ran = false; +text.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "text") { + expect(event.diff.diff).toStrictEqual([ + { + retain: 5, + attributes: { bold: true }, + }, + ]); + ran = true; + } + } +}); +text.mark({ start: 0, end: 5 }, "bold", true); +doc.commit(); +await new Promise((r) => setTimeout(r, 1)); +expect(ran).toBeTruthy(); +``` + +The types of events are defined as follows: + +```ts twoslash +import { Path, Diff, Frontiers, ContainerID } from "loro-crdt"; + +export interface LoroEventBatch { + /** + * How the event is triggered. + * + * - `local`: The event is triggered by a local transaction. + * - `import`: The event is triggered by an import operation. + * - `checkout`: The event is triggered by a checkout operation. + */ + by: "local" | "import" | "checkout"; + origin?: string; + /** + * The container ID of the current event receiver. + * It's undefined if the subscriber is on the root document. + */ + currentTarget?: ContainerID; + events: LoroEvent[]; + from: Frontiers; + to: Frontiers; +} + +/** + * The concrete event of Loro. + */ +export interface LoroEvent { + /** + * The container ID of the event's target. + */ + target: ContainerID; + diff: Diff; + /** + * The absolute path of the event's emitter, which can be an index of a list container or a key of a map container. + */ + path: Path; +} +``` + + +# FILE: pages/docs/index.mdx + +## Introduction to Loro + +It is well-known that syncing data/building realtime collaborative apps is +challenging, especially when devices can be offline or part of a peer-to-peer +network. Loro simplifies this process for you. + +We want to provide better DevTools to make building +[local-first apps](https://www.inkandswitch.com/local-first/) easy and +enjoyable. + +Loro uses [Conflict-free Replicated Data Types (CRDTs)](/docs/concepts/crdt) to +resolve parallel edits. By utilizing Loro's data types, your applications can be +made collaborative and keep the editing history with low overhead. + +After you model your app state by Loro, syncing is simple: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +const docA = new LoroDoc(); +const docB = new LoroDoc(); +docA.getText("text").insert(0, "Hello world!"); +docB.getText("text").insert(0, "Hi!"); +// Assume docA and docB are two Loro documents in two different devices +const bytesA = docA.export({ mode: "update" }); +// send bytes to docB by any method +docB.import(bytesA); +// docB is now updated with all the changes from docA + +const bytesB = docB.export({ mode: "update" }); +// send bytes to docA by any method +docA.import(bytesB); +// docA and docB are now in sync, they have the same state +``` + +Saving your app state is also straightforward: + +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello world!"); +const bytes = doc.export({ mode: "snapshot" }); +// Bytes can be saved to local storage, database, or sent over the network +``` + +Loading your app state: + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const bytes = new Uint8Array([1, 2, 3]); +// ---cut--- +const newDoc = new LoroDoc(); +newDoc.import(bytes); +``` + +Loro also makes it easy for you to time travel the history and add version +control to your app. [Learn more about time travel](/docs/tutorial/time_travel). + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const version = doc.frontiers(); +// ---cut--- +doc.checkout(version); // Checkout the doc to the given version +``` + +Loro is compatible with the JSON schema. If you can model your app state with +JSON, you probably can sync your app with Loro. Because we need to adhere to the +JSON schema, using a number as a key in a Map is not permitted, and cyclic links +should be avoided. + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +doc.toJSON(); // Get the JSON representation of the doc +``` + +import { Cards } from "nextra/components"; + + + + <>![Getting started](/images/GettingStarted.png) + + + +## Is Loro Right for You? + +### ✅ Use Loro when you need: + +- Real-time collaboration on documents +- Automatic conflict resolution for concurrent edits +- Offline editing with later synchronization +- Complete edit history and time travel +- P2P synchronization capabilities + +### ⚠️ Consider alternatives when: + +- Your application requires strong consistency +- Your data isn't JSON-like (e.g., large binary/media streaming) +- Simple client–server sync is sufficient (e.g., basic WebSockets) +- Your application is sensitive to bundle size (Loro WASM binary ~970KB gzipped) + +[Learn more about when not to use CRDTs →](/docs/concepts/when_not_crdt) + +## Differences from other CRDT libraries + +The table below summarizes Loro's features, which may not be present in other +CRDT libraries. + +| Features/Important design decisions | Loro | Diamond-types | Yjs | Automerge | +| :-------------------------------------------------------------------------- | :--- | :------------ | :---------- | :---------- | +| [Event Graph Walker](https://loro.dev/docs/advanced/replayable_event_graph) | ✅ | ✅ Inventor | ❌ | ❌ | +| Rich Text CRDT | ✅ | ❌ | ❌ | ✅ | +| [Movable Tree](https://ieeexplore.ieee.org/document/9563274) | ✅ | ❌ | ❌ | ❌ Inventor | +| [Movable List](https://loro.dev/docs/tutorial/list) | ✅ | ❌ | ❌ | ❌ Inventor | +| Time Travel | ✅ | ✅ | ✅[1] | ✅ | +| [Fugue](https://arxiv.org/abs/2305.00583) / Maximal non-interleaving | ✅ | ✅ | ❌ | ❌ | +| JSON Types | ✅ | ❓ | ✅ | ✅ | +| [Mergeable Containers](/blog/mergeable-containers) | ✅ | ❌ | ❌ | ❌ | +| Merging Elements in Memory by Run Length Encoding | ✅ | ✅ | ✅ Inventor | ❌ | +| Byzantine-fault-tolerance | ❌ | ❌ | ❌ | ✅ | +| Version Control | ✅ | ❌ | ❌ | ✅ | + +- [1] Unlike others, Yjs requires users to store a version vector and a delete + set, enabling time travel back to a specific point. +- [Fugue](https://arxiv.org/abs/2305.00583) is a text/list CRDTs that can + minimize the chance of the interleaving anomalies. + + +# FILE: pages/changelog/v1.8.0.mdx + +--- +version: "v1.8.0" +title: "Release Loro v1.8.0" +date: 2025/09/22 +--- + +Events now fire **synchronously** + +In the JavaScript package `loro-crdt@1.8.0`, we changed event emission from **“after a microtask”** to **synchronous** dispatch. This makes event handling simpler and less error‑prone. + +### Why we changed it + +Historically, the JS binding emitted events **after a microtask**. The reason was to avoid Rust borrow/aliasing issues: if an event triggered inside `doc.commit()` re‑entered the same `doc`, Rust could panic because `commit()` holds a borrow and reusing the object would violate borrowing rules. Deferring events to a microtask avoided that re‑entrancy. (Background: Loro’s JS API exposes subscriptions via `doc.subscribe(...)`; the docs note that events used to arrive after a microtask. ([Loro][1])) + +However, this deferral made app logic fragile. There was a window between the mutation and the event callback. Example: the app receives the event from CRDT “delete the 3rd character of `Hi!`”, but during the microtask gap the app state changed `Hi!` → `Hi`. When the deferred event arrives, applying a “delete index 2” delta can be wrong. In practice, users had to maintain an awkward invariant: **don’t mutate the app state during that microtask** if you consume delta updates. + +### What’s new in 1.8.0 + +Events are now **dispatched synchronously**—that is, your listeners run **before** the top‑level JS call (e.g., `doc.commit()`) returns. To keep this safe with Rust’s borrowing: + +- We keep a **global queue** of “pending (listener, event)” pairs. +- JS wrappers decorate APIs that can borrow the Rust doc (e.g., `commit`, import/export, checkout). +- The wrapper calls into WASM/Rust, **returns from the borrow**, then **flushes the queue** and clears it. +- Because callbacks run **outside** the borrowed region, listeners can freely call `doc` APIs without triggering borrow violations. + +### What this means for your app + +- **Simpler state updates.** No more microtask gap; event ordering matches your mutation order. +- **Fewer footguns with deltas.** You no longer need to uphold “don’t touch the doc during the microtask” when applying delta‑style updates. +- **Listeners can still safely use `doc`.** + + +# FILE: pages/changelog/v1.9.0.mdx + +--- +version: "v1.9.0" +title: "Release Loro v1.9.0" +date: 2025/11/10 +--- + +## Highlights + +- JSONPath queries are now RFC 9535 compliant across both Rust and WASM bindings. A new parser, evaluator, and conformance test suite (PR #848, commit `21d13218`) unlock richer filters like `in` and `contains` while delivering clearer error messages. Thanks @zolero! +- Source maps become first-class in the WASM toolchain. `loro-wasm-tools` now embeds symbols during bundling (#836) and the new `loro-wasm-map` package publishes maps next to the runtime bundle (#844), drastically simplifying debugging in browsers, Vite, and other build systems. + +## Breaking change + +- **Removed legacy v0.x encoding path (#849, `10a405b4`)**. Legacy serializers, compatibility fuzz tests, and decoding shims are gone. All runtimes now rely on the v1.* encoding format. Documents created with v0.x must be migrated via an intermediate ≤1.8.x release before installing 1.9.0. + +## New features & improvements + +- **Spec-compliant JSONPath (#848, `21d13218`)**. Ships a Pest-based parser, richer AST, the `in`/`contains` filter operators, better existence checks, and shared tests/benchmarks to keep Rust and WASM perfectly aligned. +- **WASM debugging experience (#836, `6f987022`; #844, `53f55331`)**. Adds `loro-wasm-tools` for embedding sourcemaps, automates sourcemap publishing in CI, and hosts the maps in a dedicated workspace package so source-level debugging works without manual steps. +- **Better bundler support (#852 `3af6a857`, #851 `366f0161`)**. Patches the JS entry points so esbuild and rsbuild can import the WASM bundle with zero loader overrides. +- **Bun runtime improvements (#829 `b8c070fd`, #828 `cf123453`, #827 `8b622619`, #834 `865bccba`)**. Streams the WASM binary during bundling, pre-seeds the externref table, integrates Bun into CI, and removes earlier hacks that hid loader bugs. +- **JavaScript performance (#820 `c2c535c1`)**. Event conversion now runs exactly once per callback invocation, trimming overhead during heavy real-time editing sessions. +- **Core dependency refresh (`e8f79de8`, `b2f5e107`)**. Upgrades `generic-btree` and the columnar storage crate to pick up the latest bug fixes and perf wins. + +## Bug fixes & stability + +- Fixed tree undo operations that moved nodes between siblings (#821 `76a8728e`). +- Resolved container ID bookkeeping when exporting shallow snapshots (#823 `b72a759a`) and ensured pending containers now return `None` instead of leaking state (#840 `35d9064d`). +- Prevented `LoroMap` entries from turning into `null` after `applyDiff` (#825 `3afc4d52`). +- Ensured undo manager callbacks fire without tripping Rust aliasing violations (#831 `a39daf85`). +- Guarded against panics when cursors point to deleted entries (#835 `e97e6056`) and when fetching unknown cursors in JS integration tests. +- Cleaned up WASM loader glue so we no longer rely on an extra patch (#834 `865bccba`) and added multiple Bun regression tests (#828 `cf123453`). +- Tightened CI release scripts (`74b78514`, `2383cdc1`, `4740a04c`, `3373e046`, `24f93249`) to keep `loro-wasm` artifacts and sourcemaps publishing in sync. + + +# FILE: pages/changelog/v1.2.0.mdx + +--- +version: "v1.2.0" +title: "Release Loro v1.2.0" +date: 2024/12/10 +--- + +## New + +- Add `isDeleted()` method to all container types (Text, Map, List, Tree, etc.) + +### LoroDoc + +- `changeCount()`: Get the number of changes in the oplog. +- `opCount()`: Get the number of ops in the oplog. + +### VersionVector + +- `setEnd(id: ID)`: Set the exclusive ending point. target id will NOT be included by self. +- `setLast(id: ID)`: Set the inclusive ending point. target id will be included. +- `remove(peer: PeerID)`: Remove the specific peer id. +- `length()`: Get the number of peers in the VersionVector. + +## Change + +- Return `ImportStatus` in the `importUpdateBatch` method. +- Fractional index is enabled by default now. + + +## Fix + +- fix: getOrCreateContainer should not throw if value is null [#576](https://github.com/loro-dev/loro/pull/576) +- fix: dead loop when importing updates [#570](https://github.com/loro-dev/loro/pull/570) + + +# FILE: pages/changelog/v1.4.7.mdx + +--- +version: "v1.4.7" +title: "Release Loro v1.4.7" +date: 2025/04/01 +--- + +## New + +- You can get the version of Loro by `LORO_VERSION` +- `setNextCommitOrigin(origin: string)`: Set the origin of the next commit. +- `setNextCommitTimestamp(timestamp: number)`: Set the timestamp of the next commit. +- `setNextCommitOptions(options: CommitOption)`: Set the options of the next commit. +- `clearNextCommitOptions()`: Clear the options of the next commit. +- `configDefaultTextStyle(style: TextStyle)`: Configures the default text style for the document. +- `getUncommittedOpsAsJson()`: Get the pending operations from the current transaction in JSON format + +## Fix + +- fix: memory leak issue [#647](https://github.com/loro-dev/loro/pull/647) +- fix: mark err on detached LoroText [#659](https://github.com/loro-dev/loro/pull/659) +- fix: detached loro text issues [#665](https://github.com/loro-dev/loro/pull/665) +- fix: entity index when the tree is empty [#670](https://github.com/loro-dev/loro/pull/670) + + +# FILE: pages/changelog/v1.5.0.mdx + +--- +version: "v1.5.0" +title: "Release Loro v1.5.0" +date: 2025/04/04 +--- + +## New + +### 1. New Hooks + +`doc.subscribePreCommit(listener)` - Modify commit options before processing: + +This hook is particularly useful because doc.commit() is often invoked implicitly in various methods such as doc.import, doc.export, doc.checkout, and doc.exportJsonUpdates. Without this hook, users attempting to add custom messages to each commit might miss these implicit commit triggers. + +```ts +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribePreCommit((e) => { + e.modifier.setMessage("test").setTimestamp(Date.now()); +}); +doc.getList("list").insert(0, 100); +doc.commit(); +expect(doc.getChangeAt({ peer: "0", counter: 0 }).message).toBe("test"); +``` + +Advanced Example - Creating a Merkle DAG: + +```ts no_run +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribePreCommit((e) => { + const changes = doc.exportJsonInIdSpan(e.changeMeta); + expect(changes).toHaveLength(1); + const hash = crypto.createHash("sha256"); + const change = { + ...changes[0], + deps: changes[0].deps.map((d) => { + const depChange = doc.getChangeAt(idStrToId(d)); + return depChange.message; + }), + }; + hash.update(JSON.stringify(change)); + const sha256Hash = hash.digest("hex"); + e.modifier.setMessage(sha256Hash); +}); + +doc.getList("list").insert(0, 100); +doc.commit(); + +expect(doc.getChangeAt({ peer: "0", counter: 0 }).message).toBe( + "2af99cf93869173984bcf6b1ce5412610b0413d027a5511a8f720a02a4432853", +); +``` + +`doc.subscribeFirstCommitFromPeer(listener)` - Triggers on first peer interaction: + +This hook provides an ideal point to associate peer information (such as author identity) with the document. + +```ts +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribeFirstCommitFromPeer((e) => { + doc.getMap("users").set(e.peer, "user-" + e.peer); +}); +doc.getList("list").insert(0, 100); +doc.commit(); +expect(doc.getMap("users").get("0")).toBe("user-0"); +``` + +### 2. EphemeralStore + +EphemeralStore is a better alternative to Awareness for ephemeral states: + +Awareness is commonly used as a state-based CRDT for handling ephemeral states in real-time collaboration scenarios, such as cursor positions and application component highlights. As application complexity grows, Awareness may be set in multiple places, from cursor positions to user presence. However, the current version of Awareness doesn't support partial state updates, which means even minor mouse movements require synchronizing the entire Awareness state. + +```ts no_run +awareness.setLocalState({ + ...awareness.getLocalState(), + x: 167, +}); +``` +Since Awareness is primarily used in real-time collaboration scenarios where consistency requirements are relatively low, we can make it more flexible. We've introduced EphemeralStore as an alternative to Awareness. Think of it as a simple key-value store that uses timestamp-based last-write-wins for conflict resolution. You can choose the appropriate granularity for your key-value pairs based on your application's needs, and only modified key-value pairs are synchronized. + + +```ts +import { + EphemeralStore, + EphemeralListener, + EphemeralStoreEvent, +} from "loro-crdt"; + +const store = new EphemeralStore(); +// Set ephemeral data +store.set("user-alice", { + anchor: 10, + focus: 20, + user: "Alice" +}); + +// Encode only the data for `loro-prosemirror` +const encoded = store.encode("user-alice") +const newStore = new EphemeralStore(); +newStore.subscribe((e: EphemeralStoreEvent) => { + // Listen to changes from `local`, `remote`, or `timeout` events +}); + +newStore.apply(encoded); +console.log(newStore.get("user-alice")) +// { +// anchor: 10, +// focus: 20, +// user: "Alice" +// } +``` + +## Fix + +- Fixed text styling at end "\n" character +- Added JSON support for current transaction operations +- For environments that support multi-threading such as Rust and Swift, LoroDoc can now be directly and safely + shared and accessed in parallel across multiple threads without triggering the previous WouldBlock panic. + + +# FILE: pages/changelog/v1.0.0-beta.mdx + +--- +version: "v1.0.0" +title: "Release Loro v1.0.0" +date: 2024/10/21 +# breakingChange: false +# category: ["Encoding", "Tree"] +--- + +We are very excited to announce the release of Loro v1.0, a major milestone. + +It has a stable encoding format, faster document import and export speed, better version control capabilities, and a shallow snapshot. For more information, please check [the blog](https://loro.dev/blog/v1.0). + +The following are the specific API changes: + +## New + +### LoroDoc + +- `getChange(id: ID)`: get `ChangeMeta` by `ID`. +- `setDetachedEditing(flag: boolean)`: Enables editing in detached mode, which is disabled by default. +- `isDetachedEditingEnabled()`: Whether the editing is enabled in detached mode. +- `setNextCommitMessage(msg: string)`: Set the commit message of the next commit. +- `shallowSinceVV()`: The doc only contains the history since this version. +- `shallowSinceFrontiers()`: The doc only contains the history since this version. +- `export(mode: ExportMode)`: Export the document based on the specified ExportMode. see more details [here](/docs/tutorial/encoding). +- `getDeepValueWithID()`: Get deep value of the document with container id. +- `subscribeLocalUpdates(callback:(bytes: Uint8Array) => void)`: Subscribe to updates from local edits. +- `getPathToContainer(id: ContainerID)`: Get the path from the root to the container. +- `JSONPath(jsonPath: string)`: Evaluate JSONPath against a LoroDoc. +- `forkAt(frontiers: Frontiers): LoroDoc`: Creates a new LoroDoc at a specified version (Frontiers) +- `getPendingTxnLength():number`: Get the number of operations in the pending transaction. +- `travelChangeAncestors(ids: ID[], callback: (meta: ChangeMeta)->bool)`: Iterate over all changes including the input id in order, and stop iterating if the callback returns false. + +### LoroText + +- `updateByLine(text: string)`: Update the current text based on the provided text line by line like git. + +### LoroList + +- `toArray(): ValueOrContainer[]`: Get elements of the list. If the value is a child container, the corresponding `Container` will be returned. +- `clear()`: Delete all elements in the list. + +### LoroMovableList + +- `toArray(): ValueOrContainer[]`: Get elements of the list. If the value is a child container, the corresponding `Container` will be returned. +- `clear()`: Delete all elements in the list. + +### LoroMap + +- `clear()`: Delete all key-value pairs in the map. + +### LoroTree + +- `enableFractionalIndex(jitter: number)`: Set whether to generate fractional index for Tree Position. +- `disableFractionalIndex()`: Disable the fractional index generation for Tree Position when + you don't need the Tree's siblings to be sorted. The fractional index will be always default. +- `isFractionalIndexEnabled()`: Whether the tree enables the fractional index generation. +- `isNodeDeleted(id: TreeID)`: Return `undefined` if the node is not exist, otherwise return `true` if the node is deleted. +- `getNodes(prop: getNodesProp): LoroTreeNode[]`: Get the flat array of the forest. If `with_deleted` is true, the deleted nodes will be included. + +### UndoManager + +- `clear()`: Clear the Undo and Redo stack of `UndoManager` + +## Changes + +### LoroDoc + +- Move `setFractionalIndexJitter()` to `LoroTree`, you can set whether to enable or disable it for each `Tree Container`. +- `import()`, `importWith()` and `importJsonUpdates` will return `ImportStatus` for indicating which ops have been successfully applied and which ops are pending. +- New Subscription for event. +- In Loro 1.0, `doc.version()` `doc.frontiers()` `doc.oplogVersion()` and `doc.oplogFrontiers()` even if ops has not been committed, it indicates the latest version of all operations. +- rename `Loro` to `LoroDoc`. + +### LoroTree + +- `contains(id: TreeID)`: Return true even if the node exists in the internal state and has been deleted. +- `nodes()`: deleted nodes will be included now, you can use `isDeleted()` to filter. +- `toJSON()`: Now use the hierarchical approach to express the tree structure. + +## Deprecation + +### LoroDoc + +- `exportFrom(version)` and `exportSnapshot()` are deprecated, use `export(mode: ExportMode)` instead. + + +# FILE: pages/changelog/v1.4.0.mdx + +--- +version: "v1.4.0" +title: "Release Loro v1.4.0" +date: 2025/02/13 +--- + +## New + +- add `unsubscribe()` for Subscription. + +## Fix + +- fix: getting values by path in LoroTree [#643](https://github.com/loro-dev/loro/pull/643) +- fix: should be able to call subscription after diffing [#637] +- fix: update long text may fail [#633](https://github.com/loro-dev/loro/pull/633) +- fix: map.keys() may return keys from deleted entries [#618](https://github.com/loro-dev/loro/pull/618) + + +# FILE: pages/changelog/inspector-v0.1.0.mdx + +--- +version: "v0.1.0" +title: "Release Loro Inspector v0.1.0" +date: 2025/04/30 +--- + +Try it here: [Loro Inspector](https://inspector.loro.dev/) + +Now you can directly browse the current state and complete edit history of your Loro +documents in the browser. You can also use this tool to time travel to any version +in the history of your Loro document. + +import { ReactPlayer } from "../../components/video"; + + + + +# FILE: pages/changelog/v1.6.0.mdx + +--- +version: "v1.6.0" +title: "Release Loro v1.6.0" +date: 2025/08/29 +--- + +Snapshot import speed is now 2x. + +This is implemented by skipping the scan when importing a snapshot, which avoids running the decompression during the import process (but we need to ensure the parent-child link is still accessible in Arena). It also skips the checksum check in the import_all method on MemKV because we already check the checksum in the header of the snapshot/update. + +## v1.0.0 vs v1.6.0 + +You can find the benchmark [here](https://github.com/loro-dev/latch-bench/tree/cmp-loro-160). + +| name | task | time | +| -------------------------- | ------------------------------- | ---------------------- | +| Shallow Snapshot on v1.0.0 | Import | 150.667µs +- 1.823µs | +| | Import+GetAllValues | 163.957µs +- 1.841µs | +| | Import+GetAllValues+Edit | 173.971µs +- 2.03µs | +| | Import+GetAllValues+Edit+Export | 488.848µs +- 3.621µs | +| Shallow Snapshot on v1.6.0 | Import | 82.82µs +- 507ns | +| | Import+GetAllValues | 90.376µs +- 393ns | +| | Import+GetAllValues+Edit | 103.358µs +- 1.916µs | +| | Import+GetAllValues+Edit+Export | 419.463µs +- 2.316µs | +| Snapshot on v1.0.0 | Import | 466.425µs +- 3.879µs | +| | Import+GetAllValues | 487.06µs +- 3.523µs | +| | Import+GetAllValues+Edit | 541.477µs +- 9.067µs | +| | Import+GetAllValues+Edit+Export | 2.98382ms +- 80.537µs | +| Snapshot on v1.6.0 | Import | 201.934µs +- 854ns | +| | Import+GetAllValues | 370.108µs +- 4.049µs | +| | Import+GetAllValues+Edit | 386.497µs +- 3.509µs | +| | Import+GetAllValues+Edit+Export | 2.362296ms +- 28.258µs | + + +# FILE: pages/changelog/v1.3.0.mdx + +--- +version: "v1.3.0" +title: "Release Loro v1.3.0" +date: 2025/01/09 +--- + +## New + +- UndoManager's `onPush` now can access the change event. +- add getShallowValue for each container. + +### LoroDoc + +- `toJsonWithReplacer(replacer: (k, v)=>Value)`: Convert the document to a JSON value with a custom replacer function. +- `revertTo(frontiers: Frontiers)`: Revert the document to the given frontiers. +- `findIdSpansBetween(from: Frontiers, to: Frontiers)`: Find the op id spans that between the `from` version and the `to` version. +- `exportJsonInIdSpan(idSpan: IdSpan)`: Export the readable [`Change`]s in the given [`IdSpan`]. + +## Fix + +- fix: prevent merging remote changes based on local `changeMergeInterval` config [#643](https://github.com/loro-dev/loro/pull/643) +- fix: should commit before travel_change_ancestors [#599](https://github.com/loro-dev/loro/pull/599) +- fix: panic when detach then attach [#592](https://github.com/loro-dev/loro/pull/592) +- fix: move child in current parent [#589](https://github.com/loro-dev/loro/pull/589) +- fix: panic when returned non-boolean value from text.iter(f) [#578](https://github.com/loro-dev/loro/pull/578) + + +# FILE: pages/changelog/v1.1.0.mdx + +--- +version: "v1.1.0" +title: "Release Loro v1.1.0" +date: 2024/11/09 +--- + +## New + +### LoroDoc + +- `forkAt(frontiers: Frontiers)`: Fork the document at the given frontiers. +- `getChangedContainersIn(id: ID, len: number)`: Gets container IDs modified in the given ID range. +- `` + +### LoroText + +- `getEditorOf(pos: number)`: Get the editor of the text at the given position. +- `push(s: string)`: Push a string to the end of the text. + + +### LoroMap + +- `getLastEditor(key: string)`: Get the peer id of the last editor on the given entry + +### LoroList + +- `getIdAt(pos: number)`: Get the ID of the list item at the given position. +- `pushContainer(child: Container)`: Push a container to the end of the list. + +### LoroMovableList + +- `getCreatorAt(pos: number)`: Get the creator of the list item at the given position. +- `getLastMoverAt(pos: number)`: Get the last mover of the list item at the given position. +- `getLastEditorAt(pos: number)`: Get the last editor of the list item at the given position. +- `pushContainer(child: Container)`: Push a container to the end of the list. + +### LoroTree + +- `toJSON()`: Get JSON format of the LoroTreeNode. + +## Fix + +- fix get correct encode blob info [#545](https://github.com/loro-dev/loro/pull/545) +- fix: avoid creating non-root containers that doesn't exist by get_container api [#541](https://github.com/loro-dev/loro/pull/541) +- fix: define the fork behavior when the doc is detached [#537](https://github.com/loro-dev/loro/pull/537) + + +# FILE: pages/blog/v1.0.mdx + +--- +title: "Loro 1.0" +date: 2024/10/23 +keywords: "loro, crdt, event graph walker" +description: "Announcing Loro 1.0: Introducing a stable encoding schema, 10-100x faster document import, advanced version control, and more for efficient real-time collaboration and local-first software development." +image: https://i.ibb.co/T1x1bSf/IMG-8191.jpg +--- + +# Loro 1.0 + +import Authors, { Author } from "../../components/authors"; + + + + +import Image from "next/image"; + +Loro is a [Conflict-free Replicated Data Type (CRDT)](https://crdt.tech/) +library that developers can use to implement real-time collaboration and version +control in their applications. You can use Loro to create +[local-first software](https://www.inkandswitch.com/local-first/). Loro 1.0 has +a stable data format, excellent performance, and rich features. You can use it +in Rust, JS (via WASM), and Swift. + +
+What is CRDT? What is it used for? + +Distributed states are now ubiquitous in multi-user collaborative applications +and applications that need multi-device synchronization. You need to ensure +consistency across devices. CRDTs provide a decentralized way to automatically +solve this problem. + +> CRDTs automatically resolve conflicts and ensure the consistency of the data. +> Some CRDT algorithms provide extra properties for merge results, which should align with user expectations as much as possible. + +CRDT provides a decentralized way to solve this problem. The decentralization here not only means that it can synchronize through P2P methods, but it also means: + +- It allows applications to naturally support offline editing +- It allows users to store and implement two-way synchronization of data in + multiple different locations +- It makes it easier for the backend to implement horizontal scaling +- It can easily support end-to-end encryption + +CRDTs were once considered unable to be used in serious and complex scenarios, +such as rich text, but optimizations in recent years have greatly expanded its +application scenarios, making it a practical and easy-to-use technology. + +Based on CRDT, we can create applications that +allow users to fully control data ownership. These applications can be like +Git-managed repositories, not relying on specific software service providers. +Users can switch between GitHub, GitLab, self-hosted Git servers, and the data +is always available locally. This is the vision of +[local-first software](https://www.inkandswitch.com/local-first/). + + + +CRDTs often also provides a simpler and easier-to-use sync method, +because for Op-based CRDTs like Loro, as long as the sets of CRDT operations +received by two peers are consistent, the CRDT document states of these two +peers are consistent. You don't have to worry about idempotency, the order of +operation application, and network exception handling. For Loro's CRDT document, +just two rounds of data exchange can transmit the missing operations between two +documents to achieve final consistency: + +> You can find all the code samples in this blog [here](https://github.com/zxch3n/loro-blog-examples) + +```jsx +import { LoroDoc, VersionVector } from "npm:loro-crdt@1.13.3"; + +const docA = new LoroDoc(); +const docB = new LoroDoc(); +docA.setPeerId(0); +docA.setPeerId(1); + +docA.getText("text").insert(0, "Hello!"); +docB.getText("text").insert(0, "Hi!"); +const versionA: Uint8Array = docA.version().encode(); +const versionB: Uint8Array = docB.version().encode(); + +// Exchange versionA and versionB Info +const bytesA: Uint8Array = docA.export({ + mode: "update", + from: VersionVector.decode(versionB), +}); +const bytesB: Uint8Array = docB.export({ + mode: "update", + from: VersionVector.decode(versionA), +}); + +// Exchange bytesA and bytesB +docB.import(bytesA); +docA.import(bytesB); + +console.log(docA.getText("text").toString()); // Hello!Hi! +console.log(docB.getText("text").toString()); // Hello!Hi! +``` + +
+ +A minimum of one round of data exchange can ensure consistency + + +```jsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; + +const docA = new LoroDoc(); +const docB = new LoroDoc(); +docA.setPeerId(0); +docA.setPeerId(1); +docA.getText("text").insert(0, "Hello!"); +docB.getText("text").insert(0, "Hi!"); + +// Exchange versionA and versionB Info +const bytesA: Uint8Array = docA.export({ + mode: "update", +}); +const bytesB: Uint8Array = docB.export({ + mode: "update", +}); + +// Exchange bytesA and bytesB +docB.import(bytesA); +docA.import(bytesB); + +console.log(docA.getText("text").toString()); // Hello!Hi! +console.log(docB.getText("text").toString()); // Hello!Hi! +``` + +If a nested child container may be created lazily by multiple peers under the +same Map key, use [Mergeable Containers](/blog/mergeable-containers) instead of +regular `setContainer`. + +
+
+ +## Features of Loro 1.0 + +### High-performance CRDTs + +High-performance, general-purpose CRDTs can significantly reduce data synchronization +complexity and are crucial for local-first development. + +However, large CRDT documents may face challenges with loading speed and memory consumption, +especially when dealing with those with extensive editing histories. +Loro 1.0 addresses this challenge through a new storage format, achieving a 10x improvement in +loading speed. In [benchmarks using Loro with real-world editing data](#document-import-speed-benchmarks), +we've reduced the loading time for a document with millions of operations from 16ms to 1ms. When utilizing the +shallow snapshot format (discussed later), the time can be further reduced to 0.37ms. +As a result, Loro will not become a bottleneck for applications dealing with such large documents. +It expands the potential use cases for CRDTs, making them viable for a wider range of applications. + +### Rich CRDT types + +Loro now supports +[rich text CRDT](https://loro.dev/blog/loro-richtext), +which enhances the merge result of rich text (text with formatting and styling) to better align with user expectations. +Our text/list CRDT is based on the [Fugue](https://arxiv.org/abs/2305.00583) algorithm. +It prevents interleaving issues when merging concurrent edits. For example, +it can avoid unintended merges like "1H2i3" when "123" and "Hi" are inserted concurrently. + +We also support: + +- Movable List: Supports set, insert, delete, and move operations. The algorithm ensures that after + merging concurrent moves, each element occupies only one position. +- Map: Similar to a JavaScript object. +- [Movable Tree](https://loro.dev/blog/movable-tree): Used to model file directories, outliners, and + other hierarchical structures that may need moving. It ensures no cyclic dependencies exist in the + tree after merging concurrent move operations. + +Loro also supports nesting between types, so you can model edits on JSON documents through them: + +> You can find all the code samples in this blog [here](https://github.com/zxch3n/loro-blog-examples) + +```tsx +import { + LoroDoc, + LoroList, + LoroMap, + LoroText, +} from "npm:loro-crdt@1.13.3"; + +// Create a JSON structure of +interface JsonStructure { + users: LoroList< + LoroMap<{ + name: string; + age: number; + }> + >; + notes: LoroList; +} + +const doc = new LoroDoc(); +const users = doc.getList("users"); +const user = users.insertContainer(0, new LoroMap()); +user.set("name", "Alice"); +user.set("age", 20); +const notes = doc.getList("notes"); +const firstNote = notes.insertContainer(0, new LoroText()); +firstNote.insert(0, "Hello, world!"); + +// { users: [ { age: 20, name: "Alice" } ], notes: [ "Hello, world!" ] } +console.log(doc.toJSON()); +``` + +### Version control + +Like Git, Loro saves a complete directed acyclic graph (DAG) of edit history. In Loro, the DAG is used to represent the dependencies between edits, similar to how Git represents commit history. + +Loro supports primitives that allow users to switch between different versions, fork new branches, edit on new branches, and merge branches. + +Based on this operation primitive, applications can build various Git-like capabilities: + +- You can merge multiple versions without needing to manually resolve conflicts +- Applications can build rebase and squash workflows on top of Loro's version primitives. + +```jsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; + +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello, world!"); +doc.checkout([{ peer: "0", counter: 1 }]); +console.log(doc.getText("text").toString()); // "He" +doc.checkout([{ peer: "0", counter: 5 }]); +console.log(doc.getText("text").toString()); // "Hello," +doc.checkoutToLatest(); +console.log(doc.getText("text").toString()); // "Hello, world!" + +// Simulate a concurrent edit +doc.checkout([{ peer: "0", counter: 5 }]); +doc.setDetachedEditing(true); +doc.setPeerId("1"); +doc.getText("text").insert(6, " Alice!"); +// ┌───────────────┐ ┌───────────────┐ +// │ Hello, │◀─┬──│ world! │ +// └───────────────┘ │ └───────────────┘ +// │ +// │ ┌───────────────┐ +// └──│ Alice! │ +// └───────────────┘ +doc.checkoutToLatest(); +console.log(doc.getText("text").toString()); // "Hello, world! Alice!" +``` + +You can also use `doc.fork()` to create a separate doc at the current version. It is independent of the current doc, and works like a fork: + +```tsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; + +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello, world!"); +doc.checkout([{ peer: "0", counter: 5 }]); +const newDoc = doc.fork(); +newDoc.setPeerId("1"); +newDoc.getText("text").insert(6, " Alice!"); +// ┌───────────────┐ ┌───────────────┐ +// │ Hello, │◀─┬──│ world! │ +// └───────────────┘ │ └───────────────┘ +// │ +// │ ┌───────────────┐ +// └──│ Alice! │ +// └───────────────┘ +doc.import(newDoc.export({ mode: "update" })); +doc.checkoutToLatest(); +console.log(doc.getText("text").toString()); // "Hello, world! Alice!" +``` + + + +### Leveraging the potential of the [Eg-walker](https://arxiv.org/abs/2409.14252) + +import { ReactPlayer } from "../../components/video"; +import Caption from "../../components/caption"; + + + +[Event Graph Walker (Eg-walker)](/docs/advanced/event_graph_walker) is a pioneering collaboration algorithm that combines the strengths of +Operational Transformation (OT) and CRDT, two widely used algorithms for real-time collaboration. + +While OT is centralized and CRDT is decentralized, OT traditionally had an advantage +in terms of lower document overhead. CRDTs initially had higher overhead, but recent +optimizations have significantly reduced this gap, making CRDTs increasingly competitive. +Eg-walker leverages the best aspects of both approaches. + +Not only have we use the idea of Eg-walker for Text and List CRDTs in Loro, but +Loro's overall architecture has also been greatly inspired by Eg-walker. As a +result, Loro closely resembles Eg-walker in terms of algorithmic properties. + + + +[The Eg-walker paper](https://arxiv.org/abs/2409.14252) was released in +September 2023. Prior to its official publication, Joseph Gentle shared an +initial version of the algorithm in the Diamond-Type repository. Excited by +the design, I implemented a similar algorithm in Loro two years ago. A brief +introduction to this algorithm can be found +[here](https://loro.dev/docs/advanced/event_graph_walker). + +The properties of Eg-walker includes: + +- It itself conforms to the definition of CRDT, so it has the strong eventual + consistency property of CRDT, thus can be used in distributed environments +- Fast local operation speed: compared to previous CRDTs, it processes + operations extremely fast because it doesn't need to generate corresponding + Operations based on CRDT data structures +- Fast merging of remote operations: The complexity of OT merging remote + operations is O(n^2), while Eg-walker, like mainstream CRDTs, is O(nlogn), + only reaching O(n^2) in extremely rare worst-case scenarios. This means that + when the number of concurrent operations reaches 10,000, OT will start to show + noticeable lag to users, while CRDTs can handle it easily. And in most + real-world scenario benchmarks, it's faster than other CRDTs. +- Lower memory usage: Because it doesn't need to persistently store CRDT + structures in memory, its memory usage is lower than general CRDTs +- Faster import speed: CRDT documents often take a long time to load because + they need to parse the corresponding CRDT structures or operations to build the CRDT + data structures. Without these structures, they cannot continue subsequent + editing, resulting in long import times. Eg-walker, like OT algorithms, only + needs the current document state and does not need to build these additional + structures to allow users to start editing the document directly, thus + achieving much faster import speed + + + +In the past quarter, we have made significant architectural adjustments to allow +Loro to further leverage the advantages of the Eg-walker algorithm. Here are our +achievements + +#### Shallow Snapshot + +By default, Loro stores the complete editing history of the document like Git, +because +[the Eg-walker algorithm needs to load edits that are parallel to them and to the least common ancestor when merging remote edits](https://loro.dev/docs/advanced/event_graph_walker). +Shallow Snapshot is like Git's Shallow Clone, which can remove old historical +operations that users don't need, greatly reducing document size and improving +document import and export speed. This allows you to cold store document history +that is too old and mainly use shallow doc for collaboration. Here's +an example usage: + +```jsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; + +const doc = new LoroDoc(); +for (let i = 0; i < 10_000; i++) { + doc.getText("text").insert(0, "Hello, world!"); +} +const snapshotBytes = doc.export({ mode: "snapshot" }); +const shallowSnapshotBytes = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), +}); + +console.log(snapshotBytes.length); // 5421 +console.log(shallowSnapshotBytes.length); // 869 +``` + +For details on the implementation principle, see +[Shallow Snapshot](/docs/advanced/shallow_snapshot). + +#### Optimized Document Format + +Loro version 1.0 has achieved a 10x to 100x improvement in document import speed +compared to version 0.16, which already has a fast import speed. +It makes it possible to load a large text document with several million operations +in under a frame time. + +This is because we introduced a new snapshot format. +When a LoroDoc is initialized through this snapshot format, we don't +parse the corresponding document state and historical information until the user +actually needs that information. + + + +In Loro 1.0's snapshot format, without compression algorithms, its document size +is twice that of the old version (and other mainstream CRDTs). This additional +size mainly comes from encoding historical operations + document state in the +1.0 snapshot format, without reusing stored data between the two, while in the +old version we used the order of historical operations to encode the current +state of the document (the old version's encoding learned from +[Automerge encoding's Value Column](https://automerge.org/automerge-binary-format-spec/#_value_column)). + +Trading twice the document size for ten times the import speed is worthwhile +because import speed affects the performance of many aspects, and the import +speed of CRDT documents +[is often noticeable to users on large documents](https://loro.dev/docs/performance) +(> 16ms). It also leaves possibilities for more optimizations in the future. + + + +Inspired by the design of Key-Value Databases, we have also divided the storage +of document state and history into blocks, with each block roughly 4KB in size, +so that when users really need a piece of history, we only need to decompress +and read this 4KB of content, without parsing the entire document. This has led +to a qualitative improvement in import speed, and because the serialization +format can better compress history and state, memory usage is also lower than +before. + +The lazy loading optimization takes advantage of Eg-walker's property that "it +doesn't need to keep the complete CRDT data structure in memory at all times, +and only needs to access historical operations when parallel edits occur". + +
+How we implemented lazy loading + +In Loro 1.0, we implemented a simple [LSM (Log-structured merge-tree)](https://en.wikipedia.org/wiki/Log-structured_merge-tree) engine internally. LSM is a data structure often used to +implement Key-Value Databases, and Loro 1.0 is heavily inspired by its design. +At the time of Loro 1.0, Loro's storage implementation used get, set, and range operations of +Key-Value Database as primitives. For example, Loro stores history as a series of +ChangeBlocks, with each ChangeBlock serialized to about 4KB. Each ChangeBlock +uses its first Op Id as the Key, and the serialized binary data of the +ChangeBlock as the Value, stored in the internal LSM engine. + +In our simple LSM Engine implementation, each Block is compressed during +serialization, and decompression only occurs when the corresponding Value is +actually retrieved. This allows the import speed of the new data format to be up +to a hundred times faster than before, with even lower memory usage. So in Loro +1.0: + +1. Data integrity is checked during import +2. Loro internally stores history (History/OpLog) and state (DocState) in + blocks, loading the corresponding blocks as needed +3. The Eg-walker algorithm that Loro is based on allows documents to start + editing without complete CRDTs meta information, thus easily working with + lazy loading behavior + +Why is lazy loading valuable? Because in many use cases, we don't need to fully +load the document's history and state: + +- For example, when we receive a set of remote updates, but the Loro document + data is still in the database, and we want to know the latest state of the + document, we need to load the LoroDoc snapshot from the database, then import + the remote update set, and then get the latest document state. At this point, + most of the historical information won't be accessed. +- Sometimes in data synchronization scenarios, peer A needs to send historical + data that peer B doesn't have. It needs to import the snapshot and then + extract the historical information that B doesn't have. In this case, the + document's state doesn't need to be parsed, and the unused part of the history + doesn't need to be parsed either. +- Users don't need document history when initializing a document; only parsing + the State is necessary at this point + + ![When merging remote operations, only the modified containers and some of the related historical operations need to be visited](./v1/merge-edit.png) + + When merging remote operations, only the modified containers and some of the + related historical operations need to be visited + +What happens during import and export in the new version? Let's take a common +scenario as an example: + +In real-time collaboration sessions or local storage, we recommend developers +first store the operations from users, and then periodically perform compaction. +This compaction involves importing the old snapshot and all scattered updates +into the same LoroDoc, then exporting uniformly through the Snapshot format. In +the new version, this will involve the following: + +- First, the old version of the snapshot is imported +- The received updates may contain parallel edits, so a part of the related + parallel edit history from the old version needs to be loaded to construct the + CRDT and complete the diff calculation + - Loro internally loads and parses the data of the corresponding block to get + the corresponding history; at this point, complete document parsing does not + occur +- After the diff calculation is complete, it needs to be applied to the + corresponding States + - Loro will internally load and parse the corresponding state, and at this + point, complete document parsing does not occur either +- Export + - Unaffected history blocks or state blocks are exported as they are + - Affected blocks will be serialized to overwrite the original block, then + exported + - During export, we use a method similar to SSTable internally for the final + export + +The only data that needs to be parsed in this entire process are: + +- Meta information for each stored block +- Blocks that need to be read will be decompressed +- History Blocks / state Blocks that will be affected by Updates + + + +
+ +#### Benchmarks + +> All benchmarks results below were performed on a MacBook Pro M1 2020 + +Below is a historical comparison of Snapshot import and export speeds between +Loro versions 1.0.0-beta.1 and 0.16.12. The benchmark is based on document editing history +from the real world. Thanks to [latch.bio](http://latch.bio) for sharing the +document data. The benchmark code is available [here](https://github.com/loro-dev/latch-bench). +The document contains 1,659,541 operations. + +> In Loro, a Snapshot stores the document history along with its current state. +> The Shallow Snapshot format, similar to Git's Shallow Clone, can exclude +> history. In the benchmark below, the Shallow Snapshot has a depth=1 (only the +> most recent operation history is retained, other historical operations are +> removed) + +| task | Old Snapshot Format on 0.16.12 | New Snapshot Format | Shallow Snapshot Format | +| ------------------------------- | ------------------------------ | -------------------------- | ----------------------- | +| Import | 17.3ms +- 0.0298ms | 1.15ms +- 0.0101ms (15x) | 375µs +- 8.47µs (47x) | +| Import+GetAllValues | 17.4ms +- 0.0437ms | 1.19ms +- 0.0122ms (14.5x) | 375µs +- 1.60µs (46x) | +| Import+GetAllValues+Edit | 17.5ms +- 0.0263ms | 1.21ms +- 0.0120ms (14.5x) | 375µs +- 1.40µs (46.5x) | +| Import+GetAllValues+Edit+Export | 32.4ms +- 0.0560ms | 5.46ms +- 0.0772ms (6x) | 844µs +- 5.12µs (38.5x) | + +Here are the key points of this benchmark: + +- The Shallow Snapshot has a depth of 1, meaning it only contains the document + state and a single historical operation, which is why it's significantly + faster +- _GetAllValue_ refers to calling `doc.get_deep_value()` (in JS, it's + `doc.toJSON()` ). It loads the complete state of the document and + obtains the corresponding JSON-like structure. This represents the time spent + on CRDT parsing before a user loads a document. +- _Edit_ refers to making a local modification. As you can see, it has little + impact on the time taken because Loro doesn't need to load the complete CRDT + data structure for local operations. +- _Export_ refers to exporting the complete document data again. We expect to + further reduce the time spent here in the future, as we can continue to reuse + the encoding of unmodified Blocks from the import. + +The following shows the performance on a document after applying the editing +history from the +[Automerge Paper](https://github.com/automerge/automerge-perf/tree/master/edit-by-index) +**100 times**. You can reproduce the results [here](https://github.com/zxch3n/automerge-paper-bench). +The document contains: + +- 18,231,500 single-character insertion operations +- 7,746,300 single-character deletion operations +- 25,977,800 operations totally +- 10,485,200 characters in the final document + +| Snapshot Type | Size (bytes) | +| ---------------- | ------------ | +| Old snapshot | 27,347,374 | +| New snapshot | 23,433,380 | +| Shallow Snapshot | 4,388,215 | + +- The New snapshot data is smaller because it performs additional simple + compression on each Block during encoding internally + +| task | Old Snapshot | New Snapshot | Shallow Snapshot | +| -------------------------- | ---------------- | ---------------------- | ---------------------- | +| Parse | 538ms +- 3.23ms | 17.9ms +- 48.5µs (30x) | 14.4ms +- 114µs (37x) | +| Parse+ToString | 568ms +- 1.78ms | 20.2ms +- 57.2µs (28x) | 16.8ms +- 81.4µs (34x) | +| Parse+ToString+Edit | 561ms +- 940µs | 119ms +- 180µs (4.5x) | 113ms +- 185µs (5x) | +| Parse+ToString+Edit+Export | 1460ms +- 22.9ms | 251ms +- 1.60ms (6x) | 206ms +- 360µs (7x) | + +## Next Steps for Loro + +### Loro Version Controller + + + +Importing Loro Git Repo into Loro Version Controller + +Loro's performance on a single document is now sufficient to cover the real-time +collaboration and version management needs of most documents. So our next step +will be to explore real-time collaboration and version control across a +collection of documents. + +We believe that CRDTs can create a Git for Everyone and Everything: + +- It's for Everyone because by leveraging the power of CRDTs, we can make version + control much easier to reason about and use for the average person. +- It's (nearly) for Everything because Loro provides a rich set of data + synchronization types. We're no longer limited to synchronizing plain text + data, but can solve semantic automatic merging of JSON-like schema, which can meet + most needs of creative tools and collaborative tools. + +We've created a demo of the Loro version controller, which is based on our +sub-document implementation (implemented in the application layer) with Version information. +It can import the entire React repository (about 20,000 commits, thousands of +collaborators), and it supports real-time collaboration on such +repositories. However, how to better manage versions and seamlessly integrate with Git still needs to be explored. + + + +Loro CRDTs still have significant room for optimization in these scenarios. +At the time of Loro 1.0, the Loro CRDTs library didn't involve network or disk +I/O, which enhanced its ease of use but also constrained its capabilities and +potential optimizations. +For example, while we've implemented block-level storage, documents are still +imported and exported as whole units. Adding I/O capabilities to selectively +load/save blocks would enable significant performance optimizations. + +## Conclusion + +Loro 1.0 features great performance improvements, rich CRDT types, and advanced +version control features. Our optimized document format has yielded promising +results on the import speed and the memory usage. + +Now that Loro CRDTs are stable, we are able to develop a better ecosystem. +We're excited to see it being applied in various scenarios. +If you're interested in using Loro, welcome to join our +[Discord community](https://discord.gg/tUsBSVfqzf) for discussions. + +For current updates, follow the [changelog](/changelog) or join the +[Discord community](https://discord.gg/tUsBSVfqzf). + + +# FILE: pages/blog/mergeable-containers.mdx + +--- +title: "Mergeable Containers: Fixing Concurrent Child Creation" +date: 2026/06/09 +description: "Mergeable Containers let Loro peers concurrently create the same child container under a Map key and still merge into one shared child, by deriving identity from the logical parent/key/type instead of the creation OpID." +image: "/images/blog-mergeable-containers.png" +--- + +# Mergeable Containers: Fixing Concurrent Child Creation + +import Authors, { Author } from "../../components/authors"; + + + + + +![Mergeable Containers overview](/images/blog-mergeable-containers.png) + +Two users are offline. Both add content to the same empty note. They come back online, sync finishes, and one user's edits seem to disappear. + +There is no error, and the data is not actually gone from history. But `note.get("body")` can only return one Text container. The other container was created concurrently and still exists in history, but it is no longer visible in the current document state. From the application's point of view, this looks like data loss. + +This is a classic problem in JSON-like CRDTs. Users have run into versions of it in the Loro, Yjs, and Automerge communities. The [Appendix](#appendix-runnable-reproductions) has short scripts that reproduce it in all three. + +Loro now solves this with Mergeable Containers. They make a child container's identity come from its logical position in the `Map`, not from the ID of the operation that happened to create it. + +Special thanks to [Alexis Williams](https://github.com/typedrat) from [Synapdeck](https://synapdeck.com/) for the substantial implementation work and design discussion behind this feature. + +From the user's point of view, the API change is small. Instead of creating an on-demand child container like this: + +```ts no_run +// Peer A +doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "A"); + +// Peer B, offline +doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "B"); + +// after sync: only one List is visible at "2026-06-08" +``` + +you can use a mergeable child: + +```ts no_run +// Peer A +doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "A"); + +// Peer B, offline +doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "B"); + +// after sync: both peers edit the same List +``` + +As a rule of thumb, use `ensureMergeable*` when a child container should be identified by its logical position: + +```ts no_run +map.ensureMergeableText(key); +map.ensureMergeableMap(key); +map.ensureMergeableList(key); +map.ensureMergeableMovableList(key); +map.ensureMergeableTree(key); +map.ensureMergeableCounter(key); +``` + +Use them for fields that should behave like one shared child container for everyone: one shared Text, one shared List, one shared Map, and so on. It should not matter which peer creates that child first. The rest of this post walks through why the problem exists and how the new encoding works. + +## Why This Happens + +CRDTs are usually good at cases like "multiple users editing the same text at the same time" or "multiple users inserting into the same list concurrently." This issue happens one layer earlier: before the peers can edit the same List, Text, or Map, they first need to agree on which child container that key refers to. + +Before Mergeable Containers, the recommended workaround was to initialize all required child containers as soon as the parent `LoroMap` was created. For example, if every note always needs a `body` text, creating that `body` together with the note avoids the first-creation race. + +That workaround is useful, but it has limits. Some applications cannot know every child container ahead of time. A schema migration may add a new child container to existing documents. A calendar-like document may create child containers by date. A dynamic index may create one child container per user-defined key. In these cases, on-demand creation is natural, and concurrent first creation is hard to avoid. + +The root cause is the way regular child Container IDs are represented. A normal child Container ID includes the `OpID` that created it. Concurrent first creation therefore creates different Container IDs, and the Map conflict-resolution rule decides which one is visible. + +The issue is not that List insertion cannot merge. Once both peers are editing the same List, List edits merge normally. The issue is that the two peers created two different Lists at the same Map key. + +## Why Root Containers Are Naturally Mergeable + +In Loro and Yjs, top-level Root Containers are usually accessed by name: + +```ts no_run +doc.getMap("state"); +doc.getText("content"); +``` + +Here, `"state"` or `"content"` is already a stable identity. It does not depend on which peer created it or which operation created it. As long as multiple peers access the same root name, they naturally refer to the same logical Container. + +> Automerge has a different object identity model, so this root-container comparison is specifically about Loro and Yjs. The broader issue is still similar: when composite values are created concurrently at the same key, the system needs a rule for which object identity becomes visible. + +Regular child Containers are different. Their identity is tied to the operation that created them, so two concurrent "first creations" become two different objects. + +Mergeable Containers bring the useful part of Root Container identity to selected child Containers: the child identity comes from a deterministic name, not from the creation operation. + +## API: Explicitly Ensuring a Mergeable Child + +This feature does not change the existing `setContainer` / `insertContainer` behavior. It adds explicit `ensureMergeable*` APIs for the mergeable case. In Rust, the same methods use snake case: + +```rust +map.ensure_mergeable_text("body")?; +map.ensure_mergeable_map("profile")?; +``` + +The word `ensure` is intentional. It returns the child and, if needed, writes the marker that makes it visible at that key. Calling the same method again for the same type is idempotent. + +If the key already holds a regular scalar value or a regular child Container, the API returns an error instead of silently overwriting it. + +One subtle case is type changes. If one peer asks for a mergeable Text at `"field"` while another peer asks for a mergeable Map at the same key, Loro still needs one visible value at that key. The Map's normal conflict rule decides which type is visible. The non-visible mergeable child's state is still preserved under its deterministic ID, so switching back to that type can resurface it later. + +## Core Design: Deterministic CID + Map Slot Marker + +Mergeable Containers have two separate layers of representation: + +1. The child Container ID derived from the parent Container ID, key, and type. This decides whether peers address the same CRDT object. +2. The parent Map slot. This decides whether that object is currently visible at a key, and which mergeable child type is active there. + +Keeping these two layers separate makes the behavior easier to reason about. + +## 1. CID: A Synthetic Root Container ID + +A Mergeable Container uses a synthetic `ContainerID::Root` under an internal namespace. User-created root names cannot use this prefix, so ordinary roots cannot collide with mergeable CIDs: + +```text +🤝: +``` + +The payload is derived from the parent Map and the key. The Container type stays in `ContainerID::Root.container_type`, just like ordinary Root Containers. This lets all peers derive the same child ID without using the creation `OpID`. + +The current encoding keeps nested mergeable Map IDs linear in the logical path length. This change was made before release to avoid recursive CID growth for deeply nested mergeable maps. + +
+More details: the flattened CID encoding + +After [PR #1002](https://github.com/loro-dev/loro/pull/1002), the payload no longer recursively embeds the full parent CID. Instead, it uses a flattened path: + +```text +payload = base-parent ">" key-1 ">" key-2 ... +``` + +The `base-parent` is the nearest non-mergeable Map ancestor: + +```text +$ +@: +``` + +For example: + +```text +Root map "state", key "note-1", child map: +🤝:$state>note-1 type = Map + +Nested key "body" under that mergeable map, child text: +🤝:$state>note-1>body type = Text +``` + +Parsing the second CID gives: + +```text +parent = Root("🤝:$state>note-1", Map) +key = "body" +type = Text +``` + +
+ +## 2. Map Slot: A Binary Marker Controls Visibility + +A deterministic CID alone is not enough because Loro has multiple Container types. If one peer calls `ensureMergeableText("field")` while another peer concurrently calls `ensureMergeableMap("field")`, both deterministic child CIDs can exist. The parent Map still needs to decide which type is currently visible at `"field"`. That decision needs to be deterministic and reversible: switching the visible type should not destroy the state of the other mergeable child. + +So Loro stores a small activation marker in the parent Map slot. Its meaning is: + +```text +At this key of this parent Map, activate a mergeable child of this type. +``` + +When a new Loro client reads the slot, it uses the current `parent id + key + kind` to derive the deterministic mergeable CID, then presents it through the public API as a normal Container: + +```ts no_run +const body = map.get("body"); +// body is a LoroText, not the internal binary marker +``` + +When the key is deleted, only the marker is removed. The mergeable child state is not immediately destroyed, because the parent slot controls visibility rather than the child's stored history. Calling this again: + +```ts no_run +map.ensureMergeableText("body"); +``` + +resurfaces the same deterministic Text Container. + +The marker is also bound to its exact parent, key, and type. That keeps it from accidentally activating a mergeable child if the same binary value is copied somewhere else. + +
+More details: the binary marker format + +The marker is a compact binary value: + +```text +MAGIC[4] + KIND[1] + DIGEST[3] +``` + +`DIGEST` is the low 24 bits of CRC32 over `(parent_id, key, kind)`. So the marker is not a magic value that can be copied anywhere. + +If a user copies the marker binary from one key to another key, or from one parent Map to another, new Loro clients will not recognize it as a valid mergeable child marker. It remains an ordinary binary value. + +This matters because `LoroValue::Binary` is still valid user data. Without binding the marker to parent, key, and type, copying a binary value could accidentally activate a mergeable Container somewhere else. + +### Why Not Use a Reserved Keyword? + +One possible approach would be to store a special string or JSON object: + +```json +{ "__loro_mergeable_container__": "Text" } +``` + +or: + +```text +"__loro_mergeable_text__" +``` + +But that would take over part of the user data space. `LoroMap` is a general-purpose Map, and users may legitimately store such strings or objects. Reserved keywords would make ordinary user values suddenly have special meaning. + +They are also hard to bind safely to parent, key, and type. If a string marker is copied somewhere else, it still looks like a marker. Avoiding accidental activation would require extra validation fields, which would make the format longer and more fragile. + +A binary marker fits this role better: it is low-level structural metadata, not business data. Older clients that do not understand Mergeable Containers see it as an ordinary binary value, rather than misinterpreting it as a child Container reference. + +### Why Not Store the Full ContainerID in the Slot? + +Another possible design would be to store the full deterministic ContainerID directly in the parent Map slot. + +The problem is that older clients may interpret it as a regular child Container edge. That would give them the wrong view of the document structure. + +Mergeable Containers need more than "a pointer to a Container." The design also needs to preserve these rules: + +- The same `(parent, key, type)` deterministically produces the same CID. +- Deleting the key hides the child, but does not delete the child state. +- Conflicts between different mergeable child types still use the Map's normal LWW rule. +- The marker must only activate at the correct parent/key/type. +- Older clients must not mistake it for a normal child Container edge. + +The marker is better understood as an activation marker. New clients derive the actual child CID from the surrounding context. + +
+ +## What This Solves for Users + +Mergeable Containers are especially useful when eager initialization is not practical. + +For example, suppose an application stores one child List per date: + +```ts no_run +const days = doc.getMap("days"); +const entries = days.ensureMergeableList("2026-06-08"); +entries.insert(0, "meeting notes"); +``` + +Or suppose a schema migration lazily adds a new child Map to existing records: + +```ts no_run +const record = doc.getMap("records").ensureMergeableMap(recordId); +const metadata = record.ensureMergeableMap("metadata_v2"); +metadata.set("migrated", true); +``` + +In both cases, the child container identity no longer depends on which peer created it first. It depends on the logical position in the document structure. + +This makes Mergeable Containers especially useful for: + +- date-keyed child lists or maps +- schema migrations that add new child containers lazily +- dynamic per-user or per-entity subdocuments +- revision counters +- settings maps whose keys are discovered over time + +## Cost and Compatibility + +Mergeable Containers have some metadata cost. Their CIDs carry logical path information, so deeper paths and longer keys produce larger IDs. [PR #1002](https://github.com/loro-dev/loro/pull/1002) changed the encoding so nested mergeable Map IDs grow linearly instead of recursively, but very deep mergeable Map chains are still better to avoid. + +The compatibility story is intentionally conservative: + +- Existing `setContainer` / `insertContainer` behavior is unchanged. +- Existing documents can be read normally by new versions. +- Mergeable Containers are introduced through new APIs, without changing existing method signatures. +- Older clients that do not understand this feature see the parent slot marker as an ordinary binary value, not as a fake child Container edge. They can preserve and sync the data, but they will not display the mergeable child with the new semantics. +- User-created root names that start with the internal `🤝:` prefix are rejected by Loro's root-name validator, so they cannot collide with mergeable CIDs. + +## Summary + +Mergeable Containers are for child Containers whose identity should come from their logical position, not from whichever peer created them first. + +Use `ensureMergeable*` when: + +- the key is dynamic or lazily created +- different peers may initialize the same child while offline +- the child should behave like one shared Text, List, Map, Tree, or Counter +- deleting the key should hide the child without treating its internal history as immediately destroyed + +Keep using `setContainer` / `insertContainer` when: + +- each creation should produce a distinct child object +- the parent slot should point to exactly the Container created by that operation +- you are modeling replacement rather than shared initialization + +The short version: if two peers creating the same child at the same Map key should mean "we both found the same child," use a Mergeable Container. + +References: + +- Loro background: [issue #759](https://github.com/loro-dev/loro/issues/759) +- Loro implementation: [PR #991](https://github.com/loro-dev/loro/pull/991), [PR #1002](https://github.com/loro-dev/loro/pull/1002) +- Related Yjs discussions: [complex diagram page](https://discuss.yjs.dev/t/how-would-you-model-a-complex-diagram-page/2114), [losing data](https://discuss.yjs.dev/t/why-am-i-losing-data/2734), [nested `Y.Map`](https://discuss.yjs.dev/t/create-y-map-is-empty/1701) +- Related Automerge discussions: [#528: failing merge for text values](https://github.com/automerge/automerge/issues/528) is the closest match; [#526: conflict resolution for replaced arrays and objects](https://github.com/automerge/automerge/issues/526) is useful background on object identity and conflict handling; the historical [automerge-classic #4](https://github.com/automerge/automerge-classic/issues/4) also covers concurrently created objects under the same key. + +## Appendix: Runnable Reproductions + +The snippets below are self-contained and run directly on Node (tested on Node 24; any Node 18+ with ESM works). Install the three libraries once: + +```bash +npm install loro-crdt@^1.13 yjs @automerge/automerge +``` + +Save each block as a `.mjs` file and run it with `node file.mjs`. They all model the same scenario from this post: two offline peers concurrently create a child container under the same `Map` key, then sync. The Loro example also shows the `ensureMergeable*` fix. + +### Loro — the bug, and the fix + +```js no_run +// loro.mjs — node loro.mjs +// In plain Node import from "loro-crdt/nodejs"; the bare "loro-crdt" entry +// targets a bundler. With Vite/webpack, import from "loro-crdt" instead. +import { LoroDoc, LoroList } from "loro-crdt/nodejs"; + +function sync(a, b) { + const va = a.export({ mode: "update" }); + const vb = b.export({ mode: "update" }); + a.import(vb); + b.import(va); +} + +// 1. The bug: concurrent setContainer at the same key +{ + const a = new LoroDoc(); + const b = new LoroDoc(); + a.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "A"); + b.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "B"); + sync(a, b); + console.log( + "setContainer ->", + JSON.stringify(a.getMap("days").get("2026-06-08").toArray()), + ); + // -> ["A"] or ["B"], never both: only one peer's List survives. + // (which one wins depends on the randomly-assigned peer IDs) +} + +// 2. The fix: concurrent ensureMergeableList at the same key +{ + const a = new LoroDoc(); + const b = new LoroDoc(); + a.getMap("days").ensureMergeableList("2026-06-08").insert(0, "A"); + b.getMap("days").ensureMergeableList("2026-06-08").insert(0, "B"); + sync(a, b); + console.log( + "ensureMergeable ->", + JSON.stringify(a.getMap("days").get("2026-06-08").toArray()), + ); + // -> both entries, e.g. ["A","B"] (order may vary): both peers share one List. +} +``` + +### Yjs — the same problem + +```js no_run +// yjs.mjs — node yjs.mjs +import * as Y from "yjs"; + +const a = new Y.Doc(); +const b = new Y.Doc(); + +// Peer A and Peer B each create a Y.Array at the same key, offline. +{ + const l = new Y.Array(); + a.getMap("days").set("2026-06-08", l); + l.insert(0, ["A"]); +} +{ + const l = new Y.Array(); + b.getMap("days").set("2026-06-08", l); + l.insert(0, ["B"]); +} + +// Sync both ways. +Y.applyUpdate(a, Y.encodeStateAsUpdate(b)); +Y.applyUpdate(b, Y.encodeStateAsUpdate(a)); + +console.log( + "yjs ->", + JSON.stringify(a.getMap("days").get("2026-06-08").toArray()), +); +// -> ["A"] or ["B"], never both: one peer's child Y.Array wins, the other is dropped. +``` + +### Automerge — the same problem + +```js no_run +// automerge.mjs — node automerge.mjs +import * as A from "@automerge/automerge"; + +let base = A.from({ days: {} }); +let a = A.clone(base); +let b = A.clone(base); + +// Peer A and Peer B each create a list at the same key, offline. +a = A.change(a, (d) => { + d.days["2026-06-08"] = ["A"]; +}); +b = A.change(b, (d) => { + d.days["2026-06-08"] = ["B"]; +}); + +let merged = A.merge(A.clone(a), b); +console.log("automerge visible ->", JSON.stringify(merged.days["2026-06-08"])); +// -> ["A"] or ["B"], never both: one list wins. +console.log( + "automerge conflicts ->", + JSON.stringify(A.getConflicts(merged.days, "2026-06-08")), +); +// -> both lists keyed by op id: the losing list is retained but hidden, +// reachable only via getConflicts(). + +// Control: when the child is created ONCE up front, concurrent edits merge. +let shared = A.from({ days: { "2026-06-08": [] } }); +let c = A.clone(shared), + d = A.clone(shared); +c = A.change(c, (x) => { + x.days["2026-06-08"].push("A"); +}); +d = A.change(d, (x) => { + x.days["2026-06-08"].push("B"); +}); +let ok = A.merge(A.clone(c), d); +console.log("automerge pre-created ->", JSON.stringify(ok.days["2026-06-08"])); +// -> ["A","B"] (order may vary): both survive — this is the eager-init workaround. +``` + +Note one difference worth calling out: in Automerge the losing child is retained and can be recovered through `getConflicts()`, while Yjs overwrites the map key and drops the losing child outright. Either way, from the application's point of view it looks like data loss — which is exactly what Mergeable Containers avoid. + + +# FILE: pages/blog/crdt-richtext.mdx + +--- +title: crdt-richtext - Rust implementation of Peritext and Fugue +date: 2023/04/20 +keywords: crdt, richtext, peritext, fugue, loro +description: Presenting the early Rust crate that combined Peritext and Fugue's power with impressive performance, tailored specifically for rich text. This work later informed Loro's rich text CRDT. +tag: richtext +# ogImage: /images/blog/joining-vercel/x-card.png +--- + +# [crdt-richtext](https://github.com/loro-dev/crdt-richtext): Rust implementation of Peritext and Fugue + +import Authors, { Author } from "../../components/authors"; + + + + + +Presenting an early Rust crate that combines [Peritext](https://inkandswitch.com/peritext) and [Fugue](https://arxiv.org/abs/2305.00583)'s power with impressive performance, tailored specifically for rich text. This work later informed **[Loro](https://www.loro.dev/)**'s rich text CRDT. + +# What’s Peritext + +[Peritext: A CRDT for Rich-Text Collaboration](https://inkandswitch.com/peritext) + +Peritext is a novel rich-text CRDT (Conflict-free Replicated Data Type) algorithm. It is capable of merging concurrent edits in rich text format while [preserving users' intent as much as possible](https://www.inkandswitch.com/peritext/#preserving-the-authors-intent). Its primary focus is on merging the formats and annotations of rich text content, such as bold, italic, and comments. + +> 💡 The specific definition of user intent in the context of concurrent rich text editing can't be clearly explained in a few words. it's best understood through specific examples. + +Peritext is designed to solve a couple of significant challenges: + +Firstly, it addresses the anticipated problems arising from conflicting style edits. For instance, consider a text example, "The quick fox jumped." If User A highlights "The quick" in bold and User B highlights "quick fox jumped," the ideal merge should result in the entire sentence, "The quick fox jumped," being bold. However, existing algorithms might not meet this expectation, resulting in either "The quick fox" or "The" and "jumped" being bold instead. + +| Original Text | The quick fox jumped | +| -------------------------------------------- | ---------------------------- | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The **quick fox jumped** | +| Expected Merged Result | **The quick fox jumped** | +| Bad case from merging Markdown text directly | **The** quick **fox jumped** | +| Bad case from Yjs | **The quick** fox jumped | + +Additionally, Peritext manages conflicts between style and text edits. In the same example, if User A highlights "The quick" in bold, but User B changes the text to "The fast fox jumped," the ideal merge should result in "The fast" being bold. + +| Original Text | The quick fox jumped | +| ---------------------- | ------------------------ | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The fast fox jumped | +| Expected Merged Result | **The fast** fox jumped | + +What’s more, Peritext takes into account different expectations for expanding styles. For example, if you type after a bold text, you would typically want the new text to continue being bold. However, if you're typing after a hyperlink or a comment, you likely wouldn't want the new input to become part of the hyperlink or comment. + +# What’s Fugue + +Fugue is a new CRDT text algorithm, presented in _[The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing](https://arxiv.org/abs/2305.00583)_ by [Matthew Weidner](https://arxiv.org/search/cs?searchtype=author&query=Weidner%2C+M) et al., nicely solves **the interleaving problem**. + +## The interleaving problem + +The interleaving problem was proposed in the paper _[Interleaving anomalies in collaborative text editors](https://martin.kleppmann.com/2019/03/25/papoc-interleaving-anomalies.html)_ by Martin Kleppmann et al. + +An example of interleaving: + +- A type "Hello " from left to right/right to left +- B type "Hi " from left to right/right to left +- The expected result: "Hello Hi " or "Hi Hello " +- The interleaving result may look like: "HHeil lo" + - This happens when typing from right to left in RGA. + +![An example of an interleaving anomaly when using [fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) CRDT on text content. +Source: **Martin Kleppmann, Victor B. F. Gomes, Dominic P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in collaborative text editors. [https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972)](./images/richtext0.png) + +An example of an interleaving anomaly when using [fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) CRDT on text content. +Source: \*\*Martin Kleppmann, Victor B. F. Gomes, Dominic P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in collaborative text editors. [https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972) + +The [Fugue paper](https://arxiv.org/abs/2305.00583) summarizes the current state of the interleaving problems in the table. + +![Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext1.png) + +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 + +The interleaving problem sometimes are unsolvable when there are more than 2 sites. See [Fugue](https://arxiv.org/abs/2305.00583) paper Appendix B, Proof of Theorem 5 for detailed explanation. + +![The case where the interleaving problem is unsolvable +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext2.png) + +The case where the interleaving problem is unsolvable +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 + +However, we can still minimize the chance of interleaving. Fugue introduces the concept of **maximal non-interleaving** and solves it with an elegant algorithm that is easy to optimize. The definition of _maximal non-interleaving_ makes a lot of sense to me and leaves little room for ambiguity. I won't reiterate the definition here. But the basic idea is first to solve forward interleaving by leftOrigin. If there is still ambiguity, then solve the backward interleaving by rightOrigin. (The leftOrigin and rightOrigin refer to the ids of the original neighbors when the character is inserted, just like Yjs) + +# CRDT-Richtext + +Based on the algorithms of Peritext and Fugue, we made `crdt-richtext`, a lib written in Rust that provides a wasm interface. It’s available on [crates.io](http://crates.io) and npm now. + +## Example + +```tsx +import { RichText } from "crdt-richtext-wasm"; + +const text = new RichText(BigInt(1)); +text.insert(0, "ä½ å¥½ï¼Œä¸–ç•Œï¼"); +text.insert(2, "å‘€"); +expect(text.toString()).toBe("ä½ å¥½å‘€ï¼Œä¸–ç•Œï¼"); +text.annotate(0, 3, "bold", AnnotateType.BoldLike); +const spans = text.getAnnSpans(); +expect(spans.length).toBe(2); +expect(spans[0].text).toBe("ä½ å¥½å‘€"); +expect(spans[0].annotations.size).toBe(1); +expect(spans[0].annotations.has("bold")).toBeTruthy(); +expect(spans[1].text.length).toBe(4); + +const b = new RichText(BigInt(2)); +b.import(text.export(new Uint8Array())); +expect(b.toString()).toBe("ä½ å¥½å‘€ï¼Œä¸–ç•Œï¼"); +``` + +## Data structure + +We heavily use B-Trees to optimize our algorithm. We made a library called [generic-btree](https://github.com/loro-dev/generic-btree), which is written in safe Rust code, which provides a flexible foundation for our optimization efforts. + +[https://github.com/loro-dev/generic-btree](https://github.com/loro-dev/generic-btree) + +![The cached content inside B-Tree](./images/richtext3.png) + +The cached content inside B-Tree + +There are several common tasks we need to address in Text CRDT, including: + +- Finding, inserting, or deleting content at a given index: + - We use a BTree to look up and update the content + - The time complexity is O(logN), where N is the length of the content +- Finding content with a given op ID: + - We use a combination of HashMap and BTree + - The time complexity if O(logN), where N is the number of operations +- Compressing content in memory: + - To reduce the amount of memory used by storing every operation in raw format, we compress the content using the RLE tricks from Yjs and DiamondTypes. + - The insight behind this compression is that neighboring inserts and deletions tend to be continuous, so we can merge them and store less metadata. + - Commonly, every leaf node in the diagram contains a dozen of characters +- Converting index between UTF-16 and UTF-8: + - In JS, the default encoding of a string is utf16, but in Rust, the default one is utf8. Although the WASM interface can help us convert the encoding of the string, we still need to convert the _index_ of the operation. + - To solve this, `crdt-richtext` also store the UTF-16 length of the content in B-Tree. So we can query the B-Tree with either the utf8 index or the utf16 index. +- Storing the boundary of style/format/comments: + - We use the same B-Tree to store the boundary, with each subtree corresponding to a span of text or tombstones. For each node in the tree, we store which annotations start before it, start after it, end before it, or end after it. + ```rust + #[derive(Debug, PartialEq, Eq, Default, Clone)] + pub struct ElemAnchorSet { + start_before: FxHashSet, + end_before: FxHashSet, + start_after: FxHashSet, + end_after: FxHashSet, + } + ``` + - This is basically the same optimization as Peritext, except we do it on the tree. + +## Encoding + +We use columnar encoding, which was first adopted to CRDTs by Martin Kelppmann [in automerge](https://github.com/automerge/automerge-classic/pull/253). To make it easier in Rust, we created the lib [Serde Columnar: Ergonomic columnar storage encoding crate](https://www.notion.so/Serde-Columnar-Ergonomic-columnar-storage-encoding-crate-7b0c86d6f8d24e4da45a1e2ebd86741c?pvs=21). + +## Heavily tested by libFuzzer + +Test-Driven Development (TDD) provides an amazing development experience. If possible, I always write unit tests for a standalone module before moving forward. However, for algorithms like CRDTs, it is infeasible to list all possible cases manually but is easy to generate test cases automatically. This is where fuzzing tests come into play. + +Some fuzzers can track coverage information and generate mutations on the input data to maximize code coverage. LibFuzzer can also identify memory leaks and UAF problems. + +`[cargo-fuzz`](https://www.notion.so/crdt-richtext-Rust-implementation-of-Peritext-and-Fugue-c49ef2a411c0404196170ac8daf066c0?pvs=21) provides a user-friendly API for writing fuzzing tests, and it supports two fuzzers: libFuzzer and AFL. It makes the unstructured libFuzzer feel structured. So we’re able to write fuzzing tests in this way + +```rust +use arbitrary::Arbitrary; + +#[derive(Arbitrary, Clone, Debug, Copy)] +pub enum Action { + Insert { + actor: u8, + pos: u8, + content: u16, + }, + Delete { + actor: u8, + pos: u8, + len: u8, + }, + Annotate { + actor: u8, + pos: u8, + len: u8, + annotation: AnnotationType, + }, + Sync(u8, u8), +} + +pub fn fuzzing(actions: Vec) { + // run tests based on actions + ... +} + +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|actions: [Action; 100]| { fuzzing(actions.to_vec()) }); +``` + +![We will run millions of Fuzzing Tests after making big changes. The fuzzer can help us extract the most useful thousands of tests to be included into the corpus. The minor changes can be verified by running the corpus.](./images/richtext4.png) + +We will run millions of Fuzzing Tests after making big changes. The fuzzer can help us extract the most useful thousands of tests to be included into the corpus. The minor changes can be verified by running the corpus. + +We use fuzzing tests in Loro's CRDTs too. This test suite is like our safety net when we're making big tweaks to the code. It's great at spotting all our little slip-ups. + +# Performance + +## Benchmark + +- Benchmark setup + ### **B4: Real-world editing dataset** + Replay a real-world editing dataset. This dataset contains the character-by-character editing trace of a large-ish text document, the LaTeX source of this paper: [https://arxiv.org/abs/1608.03960(opens in a new tab)](https://arxiv.org/abs/1608.03960) + Source: [https://github.com/automerge/automerge-perf/tree/master/edit-by-index(opens in a new tab)](https://github.com/automerge/automerge-perf/tree/master/edit-by-index) + - 182,315 single-character insertion operations + - 77,463 single-character deletion operations + - 259,778 operations totally + - 104,852 characters in the final document + We simulate one client replaying all changes and storing each update. We measure the time to replay the changes and the size of all update messages (`updateSize`), the size of the encoded document after the task is performed (`docSize`), the time to encode the document (`encodeTime`), the time to parse the encoded document (`parseTime`), and the memory used to hold the decoded document in memory (`memUsed`). + ### **[B4 x 100] Real-world editing dataset 100 times** + Replay the [B4] dataset one hundred times. The final document has a size of over 10 million characters. As comparison, the book "Game of Thrones: A Song of Ice and Fire" is only 1.6 million characters long (including whitespace). + - 18,231,500 single-character insertion operations + - 7,746,300 single-character deletion operations + - 25,977,800 operations totally + - 10,485,200 characters in the final document + +The benchmark was conducted on a 2020 M1 MacBook Pro 13-inch on 2023-05-11. + +| N=6000 | crdt-richtext-wasm | loro-wasm | automerge-wasm | tree-fugue | yjs | ywasm | +| ---------------------------------------------------------------- | ---------------------- | ----------------------- | ------------------- | --------------------------- | ---------------------------- | ------------------- | +| [B4] Apply real-world editing dataset (time) | 176 +/- 10 ms | 141 +/- 15 ms | 821 +/- 7 ms | 721 +/- 15 ms | 1,114 +/- 33 ms | 23,419 +/- 102 ms | +| [B4] Apply real-world editing dataset (memUsed) | skipped | skipped | skipped | 2,373,909 +/- 13725 bytes | 3,480,708 +/- 168887 bytes | skipped | +| [B4] Apply real-world editing dataset (encodeTime) | 8 +/- 1 ms | 8 +/- 1 ms | 115 +/- 2 ms | 12 +/- 0 ms | 12 +/- 1 ms | 6 +/- 1 ms | +| [B4] Apply real-world editing dataset (docSize) | 127,639 +/- 0 bytes | 255,603 +/- 8 bytes | 129,093 +/- 0 bytes | 167,873 +/- 0 bytes | 159,929 +/- 0 bytes | 159,929 +/- 0 bytes | +| [B4] Apply real-world editing dataset (parseTime) | 11 +/- 0 ms | 2 +/- 0 ms | 620 +/- 5 ms | 8 +/- 0 ms | 43 +/- 3 ms | 40 +/- 3 ms | +| [B4x100] Apply real-world editing dataset 100 times (time) | 15,324 +/- 3188 ms | 12,436 +/- 444 ms | skipped | 91,902 +/- 863 ms | 112,563 +/- 3861 ms | skipped | +| [B4x100] Apply real-world editing dataset 100 times (memUsed) | skipped | skipped | skipped | 224076566 +/- 2812359 bytes | 318807378 +/- 15737245 bytes | skipped | +| [B4x100] Apply real-world editing dataset 100 times (encodeTime) | 769 +/- 37 ms | 780 +/- 32 ms | skipped | 943 +/- 52 ms | 297 +/- 16 ms | skipped | +| [B4x100] Apply real-world editing dataset 100 times (docSize) | 12,667,753 +/- 0 bytes | 26,634,606 +/- 80 bytes | skipped | 17,844,936 +/- 0 bytes | 15,989,245 +/- 0 bytes | skipped | +| [B4x100] Apply real-world editing dataset 100 times (parseTime) | 1,252 +/- 14 ms | 170 +/- 15 ms | skipped | 368 +/- 13 ms | 1,335 +/- 238 ms | skipped | + +The complete benchmark result and code is available at https://github.com/zxch3n/fugue-bench. + +It is worth noting that: + +- The benchmark for Automerge is based on `automerge-wasm`, which is not the latest version of Automerge 2.0. +- `crdt-richtext` and `fugue` are special-purpose CRDTs that tend to be faster and have a smaller encoding size. +- The encoding of `yjs`, `ywasm`, and `loro-wasm` still contains redundancy that can be compressed significantly. For more details, see [the full report](https://loro.dev/docs/performance/docsize). +- At the time of this 2023 benchmark, `loro-wasm` and `fugue` only supported plain text. + +# Discussion + +[CRDT-richtext: Rust implementation of Peritext and Fugue | Hacker News](https://news.ycombinator.com/item?id=35988046) + + +# FILE: pages/blog/loro-richtext.mdx + +--- +title: "Introduction to Loro's Rich Text CRDT" +date: 2024/01/22 +keywords: "loro, crdt, rich text, peritext, event graph walker, eg-walker, fugue" +description: + This article presents the rich text CRDT algorithm implemented in Loro, + complying with Peritext's criteria for seamless rich text collaboration. + Furthermore, it can be built on top of any List CRDT algorithms and turn them + into rich text CRDTs. +image: https://i.ibb.co/rsX5vR6/cover-long.png +--- + +# Introduction to Loro's Rich Text CRDT + +import Authors, { Author } from "../../components/authors"; + + + + + +![](./loro-richtext/cover_long.png) + +import Caption from "../../components/caption"; +import Demo from "@components/richtextDemo"; + +This article presents the rich text CRDT algorithm implemented in Loro, +complying with [Peritext]'s criteria for seamless rich text collaboration. +Furthermore, it can be built on top of any List CRDT algorithms and turn them +into rich text CRDTs. + +
+ + + Above is an online demo of Loro's rich text CRDT, built with Quill. After the + replay, you can simulate real-time collaboration and concurrent editing while + offline. You can also drag on the history view to replay the editing history. + + +If CRDTs are new to you, our article [What are CRDTs](/docs/concepts/crdt) +provides a brief introduction. + +## Background + +Loro is based on the +[Event Graph Walker (Eg-walker)](/docs/advanced/replayable_event_graph) algorithm +proposed by Joseph Gentle, but this algorithm cannot integrate the original +version of Peritext. This motivates us to create a new rich text algorithm. It +is independent of the specific List CRDTs, thus working nicely with Eg-walker, and is +developed on top of them to establish a rich text CRDT. + +Before diving into the algorithm of Loro's rich text CRDT, I'd like to briefly +introduce Eg-walker and Peritext, and why Peritext cannot be used on Eg-walker. + +
+Recap on List CRDTs + +### Recap on List CRDTs + +Unlike OT, most List-oriented CRDTs assign a unique ID to each item or +character, often corresponding to the operation ID of its insertion. With unique +IDs for each character, we can reliably reference a character or position +through its ID. + +![](./loro-richtext/list_crdt_ids.png) + +The unique ID eliminates concerns about consistent position descriptions during +synchronization. For instance, deletions are straightforward by specifying the +deleted character's ID, and insertions are described using the IDs of adjacent +characters. In cases of concurrent insertions at the same location, List CRDT +algorithms resolve the consistency issues. + +![](./loro-richtext/list_crdt_insert.png) + +![](./loro-richtext/list_crdt_delete.png) + +However, a notable limitation of List CRDTs is the use of 'tombstones'. Upon +deletion of a character, it is not fully removed but replaced with a tombstone, +maintaining the ID's position. Depending on the algorithm, this tombstone may be +removed once all participating nodes acknowledge the deletion. However, it can +be challenging to determine if all peers have received the corresponding +deletion operation. This information often means additional overhead for many +CRDTs. Thus, the simplest solution is not to perform any tombstone collection. + +![](./loro-richtext/list_crdt_tombstone.png) + +
+ +### Brief Introduction to Event Graph Walker + +Eg-walker is a novel CRDT algorithm introduced in: + +> [Collaborative Text Editing with Eg-walker: Better, Faster, Smaller](https://arxiv.org/abs/2409.14252) +> By: Joseph Gentle, Martin Kleppmann + +Eg-walker is a novel CRDT algorithm that combines the strengths of both OT and CRDTs. +It has the distributed nature of CRDT that enables P2P collaboration and data +ownership. Moreover, it achieves minimal overhead in scenarios devoid of +concurrent edits, similar to OT. + +import { ReactPlayer } from "../../components/video"; + + + +Whether in real-time collaboration or multi-end synchronization, a directed +acyclic graph (DAG) forms over the history of these parallel edits, similar to +Git's history. The Eg-walker algorithm records the history of user edits on the DAG. + +Unlike conventional CRDTs, Eg-walker can record just the original description of +operations, not the metadata of CRDTs. For instance, in text editing scenarios, +the [RGA algorithm] needs the op ID and [Lamport timestamp][Lamport] of the +character to the left to determine the insertion point. [Yjs]/Fugue, however, +requires the op ID of both the left and right characters at insertion. In +contrast, Eg-walker simplifies this by only recording the index at the time of +insertion. Loro, which uses [Fugue] upon Eg-walker, inherits these advantages. + +An index is not a stable position descriptor, as the index of an operation can +be affected by other operations. For example, if you highlight content from +`index=x` to `index=y`, and concurrently someone inserts n characters at +`index=n` where `n +What is Fugue + +Fugue is a new CRDT text algorithm, presented in +_[The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing](https://arxiv.org/abs/2305.00583)_ +by +[Matthew Weidner](https://arxiv.org/search/cs?searchtype=author&query=Weidner%2C+M) +et al., nicely solves **the interleaving problem**. + +The interleaving problem was proposed in the paper +_[Interleaving anomalies in collaborative text editors](https://martin.kleppmann.com/2019/03/25/papoc-interleaving-anomalies.html)_ +by Martin Kleppmann et al. + +An example of interleaving: + +- A type "Hello " from left to right/right to left +- B type "Hi " from left to right/right to left +- The expected result: "Hello Hi " or "Hi Hello " +- The interleaving result may look like: "HHeil lo" + - This happens when typing from right to left in RGA. + +![An example of an interleaving anomaly when using [fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) CRDT on text content. +Source: **Martin Kleppmann, Victor B. F. Gomes, Dominic P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in collaborative text editors. [https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972)](./images/richtext0.png) + +An example of an interleaving anomaly when using +[fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) +CRDT on text content. Source: \*\*Martin Kleppmann, Victor B. F. Gomes, Dominic +P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in +collaborative text editors. +[https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972) + +The [Fugue paper](https://arxiv.org/abs/2305.00583) summarizes the current state +of the interleaving problems in the table. + +![Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext1.png) + +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: +Minimizing Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 + +The interleaving problem sometimes are unsolvable when there are more than 2 +sites. See [Fugue](https://arxiv.org/abs/2305.00583) paper Appendix B, Proof of +Theorem 5 for detailed explanation. + +![The case where the interleaving problem is unsolvable +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext2.png) + +The case where the interleaving problem is unsolvable Source: Weidner, M., +Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing +Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 + +However, we can still minimize the chance of interleaving. Fugue introduces the +concept of **maximal non-interleaving** and solves it with an elegant algorithm +that is easy to optimize. The definition of _maximal non-interleaving_ makes a +lot of sense to me and leaves little room for ambiguity. I won't reiterate the +definition here. But the basic idea is first to solve forward interleaving by +leftOrigin. If there is still ambiguity, then solve the backward interleaving by +rightOrigin. (The leftOrigin and rightOrigin refer to the ids of the original +neighbors when the character is inserted, just like Yjs) + + + +### Brief Introduction to Peritext + +[Peritext] was proposed by _Geoffrey Litt et al._ It's the first paper to +discuss rich text CRDTs. It can merge concurrent edits in rich text format while +[preserving users' intent as much as possible](https://www.inkandswitch.com/peritext/#preserving-the-authors-intent). +Its primary focus is merging the formats and annotations of rich text content, +such as bold, italic, and comments. It was implemented in [Automerge] and +[crdt-richtext]. + +> 💡 The specific definition of user intent in the context of concurrent rich +> text editing can't be clearly explained in a few words. It's best understood +> through particular examples. + +Peritext is designed to solve a couple of significant challenges: + +Firstly, it addresses the anticipated problems arising from conflicting style +edits. For instance, consider a text example, "The quick fox jumped." If User A +highlights "The quick" in bold and User B highlights "quick fox jumped," the +ideal merge should result in the entire sentence, "The quick fox jumped," being +bold. However, existing algorithms might not meet this expectation, resulting in +either "The quick fox" or "The" and "jumped" being bold instead. + +| Original Text | The quick fox jumped | +| -------------------------------------------- | ---------------------------- | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The **quick fox jumped** | +| Expected Merged Result | **The quick fox jumped** | +| Bad case from merging Markdown text directly | **The** quick **fox jumped** | +| Bad case from Yjs | **The quick** fox jumped | + +Additionally, Peritext manages conflicts between style and text edits. In the +same example, if User A highlights "The quick" in bold, but User B changes the +text to "The fast fox jumped," the ideal merge should result in "The fast" being +bold. + +| Original Text | The quick fox jumped | +| ---------------------- | ------------------------ | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The fast fox jumped | +| Expected Merged Result | **The fast** fox jumped | + +What’s more, Peritext takes into account different expectations for expanding +styles. For example, if you type after a bold text, you would typically want the +new text to continue being bold. However, if you're typing after a hyperlink or +a comment, you likely wouldn't want the new input to become part of the +hyperlink or comment. + +
+![Link style should not expand](./loro-richtext/Peritext.png) +
+ +Illustration of Peritext's internal state. It uses the IDs of the character's ops to record the style ranges. In the example, the bold mark has the range of `{ start: { type: "before", opId: "9@B" }, end: { type: "before", opId: "10@B" }}` + + +### Why Original Peritext Can't Be Directly Used with Eg-walker + +On the one hand, Peritext's algorithm expresses style ranges +[through character OpIDs](https://www.inkandswitch.com/peritext/#generating-inline-formatting-operations). +Without replaying history, CRDTs based on Eg-walker cannot determine the specific +positions corresponding to these OpIDs. + +On the other hand, it's not feasible to model Peritext on Eg-walker through replaying. +This is because Eg-walker's "local backtracking suffices" relies on the algorithm +satisfying "the same operation will produce the same effect, regardless of the +current state," which Peritext does not adhere to. For example, when inserting +the character "x" at position `p`, whether "x" is bold depends on "whether `p` +is surrounded by bold" and +"[whether the tombstones at `p` contain boundaries of bold and other styles](https://arc.net/l/quote/ifxpaand)." + +## Loro's Rich Text CRDT + +### Algorithm + +Loro implements rich text using special control characters called 'style +anchors'. Each matching pair of start anchor and end anchor contains the +following information: + +- The op ID of the style operation +- The style's key-value pair +- The style's [Lamport timestamp][Lamport] +- Style expansion behavior: Determines whether newly inserted text before or + after the style boundaries should inherit the style. + +The method to determine a character's style is as follows: + +- Find all style anchor pairs that include the character, where each pair is + created by the same style operation +- Aggregate pairs according to the key. There may be multiple style pairs with + the same key but different values. In such cases, the value with the greatest + Lamport timestamp is chosen (if Lamport timestamps are equal, then use the + peer ID to break the tie) + +Contrary to +[Yjs's method of using control characters](https://www.inkandswitch.com/peritext/#adding-control-characters-to-plain-text) +for rich text, our algorithm pairs start and end anchors when they originate +from the same style operation. This approach accurately handles the following +scenarios: + +![overlap_bold](./loro-richtext/overlap_bold.png) + +These special control characters are not exposed to the user; each control +character is effectively of zero length from the user's perspective. Our data +structure supports various methods of measuring text length for indexing text +content. Besides Unicode, UTF-16, and UTF-8, we also measure our rich text +length in `Entity length`. It treats each style anchor as an entity with a +length of 1 and measures plain text in Unicode length. + +![len](./loro-richtext/len.png) + +| Concept | Definition | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Style Anchors | Control characters used in Loro denote style boundaries' start and end. They are differentiated into start and end anchors, representing a style's beginning and end. | +| Rich Text Element | A rich text element is either a span of text or a style anchor. A list of rich text elements represents the internal state of Loro's rich text. | +| Unicode Index | A method of indexing text positions in rich text. In this method, the length of the text is measured in Unicode char length, and the length of style anchors is considered 0. | +| Entity Index | A method of indexing text positions in rich text. In this method, the length of the text is measured in Unicode char length, and the length of a style anchor is considered 1. | + +#### Local Behavior + +Multiple valid insertion points can exist when users insert text at a specific +Unicode index. It occurs due to style anchors, which are zero-length elements +from the user's perspective. + +![Two Different Kinds of Indexes](./loro-richtext/two_index.png) + +For example, in the case of `Hello world`, when a user inserts content at +Unicode `index=5`, they face the choice of inserting to the left or right of +``. If the user sets the expand behavior of bold to expand forward, the new +character will be inserted to the left of ``, making the inserted text bold +as well. + +When users delete text, Loro uses an additional mapping layer to avoid deleting +style anchors within the text range. + +To model the deletion of a style, a new style anchor pair with a null value is +added. + +We can implement the following optimizations to remove redundant style anchors: + +- The style anchors that include no text can be removed. +- When styles completely negate each other, like a span of bold is canceled by a + span of unbold, we can remove their style anchors. + +All these behaviors happen locally, and the algorithm is independent of the +specific List CRDT. + +##### Behavior When Inserting Text at Style Boundaries + +Most modern rich text editors (Google Doc, Microsoft Word, Notion) behave as +follows: when new text is entered right after bold text, the new text should +inherit the bold style; when entered after a hyperlink, the new content should +not inherit the hyperlink style. Different styles have varying preferences for +text insertion positions, leading to potential conflicts. This is reflected in +the degree of freedom we have when inserting new text. + +Users interact with rich text based on text-based indexes, like the Unicode +index. Since style anchors have a Unicode Length of 0, a Unicode index with n +style anchors presents n + 1 potential insertion positions. + +We select the insertion position based on the following rules: + +1. Insertions occur before a start anchor of a style that should not expand + backward. +2. Insertions occur before style anchors that signify the end of bold-like marks + (expand = "after" or expand = "both"). +3. Insertions occur after style anchors that signify the end of link-like marks + (expand = "none" or expand = "before"). + +Rule 1 should be prioritized over rules 2 and 3 to prevent +[the accidental creation of a new style](https://github.com/inkandswitch/peritext/issues/32). + +The current method first scans forward to find the last position satisfying +rules 1 and 2. + +Then, it scans backward to find the first position satisfying rule 3. + +#### Merging Remote Updates + +Loro treats style anchors as a special element and handles them using the same +List CRDT for resolving concurrent conflicts. The logic related to rich text is +independent of the particular List CRDT. Therefore, this algorithm can rely on +any List CRDT algorithm for merging remote operations. Loro utilizes the [Fugue] +List CRDT algorithm. + +When new style anchors are inserted by remote updates, new styles are added; if +old style anchors are deleted, the corresponding old styles are removed. + +#### Strong Eventual Consistency + +The internal state of this algorithm consists of a list of elements, each either +a text segment or a style anchor. The rich text document is derived from this +internal state. + +The internal state achieves strong eventual consistency through the upstream +List CRDT. + +Identical internal states result in identical rich text documents. Hence, the +same set of updates will produce the same rich text documents, evidencing the +strong eventual consistency of this algorithm. + +### Criteria in Peritext + +[The Peritext paper](https://www.inkandswitch.com/peritext/static/cscw-publication.pdf) +specifies the intent-preserving merge behavior for rich text inline format. +Loro's rich text algorithm successfully passes all test cases outlined therein. + +#### 1. Concurrent Formatting and Insertion + +| Name | Text | +| :-------------- | :------------------ | +| Origin | Hello World | +| Concurrent A | **Hello World** | +| Concurrent B | Hello New World | +| Expected Result | **Hello New World** | + +Loro easily supports this case by treating style anchors as special elements +alongside text. + +#### 2. Overlapping Formatting + +| Name | Text | +| :-------------- | :-------------- | +| Origin | Hello World | +| Concurrent A | **Hello** World | +| Concurrent B | Hel**lo World** | +| Expected Result | **Hello World** | + +This case has been analyzed earlier. Since our style anchors contain style op ID +information, we know there are two bold segments: one from 0 to 5 and another +from 3 to 11, allowing us to merge them. + +| Name | Text | +| :-------------- | :------------------ | +| Origin | Hello World | +| Concurrent A | **Hello** World | +| Concurrent B | Hel*lo World* | +| Expected Result | **Hel*lo*** _World_ | + +Multiple style types are easily supported. + +| Name | Text | Note | +| :-------------- | :--------------------------------------------------- | :------------------ | +| Origin | Hello World | | +| Concurrent A | **Hello World**
Then
**Hello** World | Bold, then unbold | +| Concurrent B | Hello Wor**ld** | | +| Expected Result | **Hello** Wor**ld**
Or
**Hello** World | Both are acceptable | + +Like Peritext, we model unbolding by adding a new style with the key `bold` and +the value `null`. The final value of each style key on each character is +determined by the style with the greatest [Lamport] timestamp that includes the +character. Thus, it easily supports this case. + +#### 3. Text Insertion at Span Boundaries + +Insertion right after a bold style should result in the newly inserted text also +being bold. + +
+ ![bold_expand](./loro-richtext/bold_expand.png) +
+ +However, insertion right after a link style should result in the newly inserted +text not having the hyperlink style. + +
+ ![Link style should not expand](./loro-richtext/link_expand.png) +
+ +#### 4. Styles that Support Overlapping + +
![](./loro-richtext/overlap_mark.png)
+ +The problem of overlapping styles is related to how we represent them. + +We represent the rich text using +[Quill's Delta](https://quilljs.com/docs/delta/) format. + +```ts no_run +[ + { insert: "Gandalf", attributes: { bold: true } }, + { insert: " the " }, + { insert: "Grey", attributes: { color: "#cccccc" } }, +]; +``` + +An example of Quill's Delta format + +However, it cannot handle cases with multiple values assigned to the same key. +So, it's a headache to handle the styles that support overlapping. + +![](./loro-richtext/overlap_comments.png) + +For example, in the above case, the text "fox" is commented on by both Alice and +Bob. We can't represent it with Quill's Delta format directly. So the possible +workaround includes: + +**Turn the attribute value into a list** + +```ts no-run +[ + { insert: "The ", attributes: { comment: [{ ...commentA }] } }, + { + insert: "fox", + attributes: { comment: [{ ...commentA }, { ...commentB }] }, + }, + { insert: " jumped", attributes: { comment: [{ ...commentB }] } }, +]; +``` + +**Use op ID that creates the op as the key of the attribute** + +```ts no-run +[ + { insert: "The ", attributes: { "id:0@A": { key: "comment", ...commentA } } }, + { + insert: "fox", + attributes: { + "id:0@A": { key: "comment", ...commentA }, + "id:0@B": { key: "comment", ...commentB }, + }, + }, + { + insert: " jumped", + attributes: { "id:0@B": { key: "comment", ...commentA } }, + }, +]; +``` + +But both require special behaviors for both CRDT lib and for application code, +which are painful to work with. + +Finally, we found that the optimal approach to represent an overlappable style +was to use `:` as a prefix and allow users to assign a unique suffix to +create a distinct style key. This method simplifies the CRDTs library code, as +it doesn't require handling special cases. It effectively addresses scenarios +where multiple comments overlap and is also user-friendly for application +coding. + +```ts no-run +[ + { insert: "The ", attributes: { "comment:alice": "Hi" } }, + { + insert: "fox", + attributes: { "comment:alice": "Hi", "comment:bob": "Jump" }, + }, + { insert: " jumped", attributes: { "comment:bob": "Jump" } }, +]; +``` + +Following is the example code in Loro: + +```ts +const doc = new Loro(); +doc.configTextStyle({ + comment: { expand: "none" }, +}); +const text = doc.getText("text"); +text.insert(0, "The fox jumped."); +text.mark({ start: 0, end: 7 }, "comment:alice", "Hi"); +text.mark({ start: 4, end: 14 }, "comment:bob", "Jump"); +expect(text.toDelta()).toStrictEqual([ + { + insert: "The ", + attributes: { "comment:alice": "Hi" }, + }, + { + insert: "fox", + attributes: { + "comment:alice": "Hi", + "comment:bob": "Jump", + }, + }, + { + insert: " jumped", + attributes: { "comment:bob": "Jump" }, + }, + { + insert: ".", + }, +]); +``` + +## Implementation of Loro's Rich Text Algorithm + +The following is an overview of Loro's implementation as of January, 2024. + +### Architecture of Loro + +In line with the properties of Event Graph Walker, Loro uses `OpLog` and +`DocState` as the internal state. + +`OpLog` is dedicated to recording history, while `DocState` only records the +current document state and does not include historical operation information. +When applying updates from remote sources, Loro uses the relevant operations +from `OpLog` and computes the diff through a `DiffCalculator`. This diff is then +applied to `DocState`. This architecture also makes time travel easier to +implement. + +For more details, see the documentation on +[DocState and OpLog](https://loro.dev/docs/advanced/doc_state_and_oplog). + +![](./loro-richtext/apply_updates.png) + +### Implementation of Loro's Rich Text CRDT + +For rich text, Loro reuses the same `DiffCalculator` as Loro List, based on the +[Fugue] algorithm. As a result, the primary logic related to rich text is +concentrated in `DocState`. This includes expressing styles, inserting new +characters, and representing multiple index formats. + +In the representation of rich text state, we distinguish between the data +structure `ContentTree`, which expresses the text (including style anchors), and +`StyleRangeMap`, which expresses styles. + +![](./loro-richtext/text_state_arch.png) + +Both structures are built on B+Trees. + +`ContentTree` is responsible for efficient text finding, insertion, and +deletion. It can index specific insertion/deletion positions using +Unicode/UTF-8/UTF-16/Entity index. It does not store what specific style each +text segment should have. + +We built the following B+Tree structure based on our +[generic-btree library](https://github.com/loro-dev/generic-btree) to express +text in memory: + +- Each internal node in the B+Tree stores the Unicode char length, UTF-16 + length, UTF-8 length, and Entity length of its subtree. The Entity length + considers the length of style anchors as 1, otherwise 0. +- The leaf nodes of the B+Tree are text or style anchors. + +`StyleRangeMap` is responsible for efficient updating/querying of style ranges. + +In the `StyleRangeMap` B+Tree expressing styles: + +- Each internal node stores the `entity length` of its subtree. +- Each leaf node stores the collection of style information for the + corresponding range and its `entity length. + +Separating the text `ContentTree` and style `StyleRangeMap` into two structures +aims for better performance optimization. On rich text, style information is +often not abundant and tends to have good continuity, such as several paragraphs +having the same format, which can be expressed with a single leaf node. However, +our structure for storing text is unsuitable for leaf nodes with large content, +as conversion time between different encoding formats would become excessively +long. + +When a user inserts a new character at `Unicode index` = i, the following +occurs: + +- Find the position at `Unicode index` = i in `ContentTree`. +- Check if there are any adjacent style anchors at this position. If not, + directly insert. +- If there are, decide whether to insert to the left or right of the + corresponding style anchor based on its type and properties. If there are + multiple such style anchors, insert them according to the previous section on + ["Behavior When Inserting Text at Style Boundaries"](#behavior-when-inserting-text-at-style-boundaries). + +### Testing + +We have written tests for the criteria proposed by Peritext and passed all of +them. + +To ensure the correctness of our CRDTs, we have added numerous fuzzing tests to +simulate different collaborative behaviors, synchronization behaviors, and +time-travel behaviors. These tests check for the strong eventual consistency and +the correctness of internal invariants. We run these fuzzing tests continuously +for several days after every critical modification to avoid oversights. + +## How to Use + +Before using the Loro's rich text module, it is necessary to define the +configuration for rich text styles, specifying the expand behavior for different +keys and whether overlap is allowed. + +Here is an example of using Loro's rich text in JavaScript: + +```typescript +const doc = new Loro(); +doc.configTextStyle({ + bold: { + expand: "after", + }, + comment: { + expand: "none", + overlap: true, + }, + link: { + expand: "none", + }, +}); + +const text = doc.getText("text"); +text.insert(0, "Hello world!"); +text.mark({ start: 0, end: 5 }, "bold", true); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello", + attributes: { bold: true }, + }, + { + insert: " world!", + }, +] as Delta[]); + +text.insert(5, "!"); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello!", + attributes: { bold: true }, + }, + { + insert: " world!", + }, +] as Delta[]); +``` + +## Summary + +This article presents Loro's rich text algorithm design and implementation. Its +correctness is readily demonstrable. It can be built upon any existing List CRDT +algorithm. It allows Loro to support rich text collaboration using +[Eg-walker](#brief-introduction-to-replayable-event-graph) and [Fugue], combining the +strengths of multiple CRDT algorithms. + +We are continuously refining its design and actively seeking design partners. We +are open to all forms of feedback and constructive criticism. Should you have +any proposals for collaboration, please reach out to zx@loro.dev + +[Peritext]: https://www.inkandswitch.com/peritext/ +[Fugue]: https://arxiv.org/abs/2305.00583 +[Lamport]: https://en.wikipedia.org/wiki/Lamport_timestamp +[RGA algorithm]: https://www.sciencedirect.com/science/article/abs/pii/S0743731510002716 +[Yjs]: https://github.com/yjs/yjs +[Automerge]: https://github.com/automerge/automerge +[crdt-richtext]: https://github.com/loro-dev/crdt-richtext + + +# FILE: pages/blog/loro-now-open-source.mdx + +--- +title: "Loro: Reimagine State Management with CRDTs" +date: 2023/11/13 +description: + "Loro, our high-performance CRDTs library, is now open source. In this + article, we share our vision for the local-first software development + paradigm, why we're excited about it, and the current status of Loro." +--- + +# Loro: Reimagine State Management with CRDTs + +import Caption from "../../components/caption"; +import GitHub from "../../components/github"; +import Authors, { Author } from "../../components/authors"; + + + + + + +
+
+ Loro, our high-performance CRDTs library, is now open source +
+ +
.
+
+ +In this article, we share our vision for the local-first software development +paradigm, explain why we're excited about it, and discuss the current status of +Loro. + +With better DevTools, documentation, and a friendly ecosystem, everyone can +easily build local-first software. + +![Loro's 'time machine' example](./loro-now-open-source/colab_and_travel.gif) + + + You can build collaborative apps with time travel features easily using Loro. + [Play the example online](https://loro-react-flow-example.vercel.app/). + + +## Envisioning the Local-First Development Paradigm + +Distributed states are commonly found in numerous scenarios, such as multiplayer +games, multi-device document synchronization, and edge networks. These scenarios +require synchronization to achieve consistency, usually entailing elaborate +design and coding. For instance, considerations for network issues or concurrent +write operations are necessary. However, for a wide range of applications CRDTs +can simplify the code significantly: + +- CRDTs can automatically merge concurrent writes without conflicts. +- Fewer abstractions. There's no need to design specific backend database + schemas, manually execute expected conflict merges, or implement interfaces to + memory and memory to persistent structure conversions. +- Offline supports are right out of the box + +
+What are CRDTs + +### What are Conflict-Free Replicated Data Types (CRDTs)? + +CRDTs are data structures used in distributed systems that allow updates to be +merged across multiple replicas without conflicts. In this context, "replicas" +refer to different independent data instances within the system, such as the +same collaborative document on various user devices. + +CRDTs enable users to operate independently on their replicas, like editing a +document, without needing real-time communication with other replicas. The CRDTs +merge these operations, ensuring all replicas achieve "strong eventual +consistency". As long as all nodes receive the same set of updates, regardless +of the order, their data states will eventually be consistent. + +> For more details, visit +> [What are CRDTs](https://www.loro.dev/docs/concepts/crdt) + +
+ +
+When you can't use CRDTs +### When you can't use CRDTs + +CRDTs only guarantee _Strong Eventual Consistency_. You have to make sure it's +suitable for your application. + +"Strong Eventual Consistency": As long as all nodes receive the same set of +updates, their data states will ultimately become consistent regardless of their +sequence. + +Strong eventual consistency may not be acceptable in scenarios requiring +immediate consistency or transactional integrity, such as financial +transactions, exclusive resource access, or allocation. + +
+ +Since the data resides locally, client applications can directly access and +manipulate local data, offering both speed and availability. Additionally, due +to CRDTs' nature, synchronization / real-time collaboration can be achieved +without relying on centralized servers (similar to Git, allowing migration to +other platforms without data loss). With performance improvements, CRDTs +increasingly replace traditional real-time collaboration solutions in various +contexts. + +This represents a new paradigm. Local-first not only empowers users with control +over their data, but also makes developers' lives easier. + +![Local-first](./loro-now-open-source/Untitled.png) + + + The annual growth rate of the *"local-first"* star count in GitHub has reached + 40%+. + + +### Integrating CRDTs with UI State Management + +![Loro's rich text collaboration example](./loro-now-open-source/richtext.gif) + +Loro's rich text collaboration example + +Since CRDTs enable conflict-free automatic merging, the challenge of managing +distributed states shifts to "how to express operations and states on CRDTs". + +Front-end state management libraries typically require developers to define how +to retrieve State and specify Actions, as illustrated by this example from Vue's +state management tool, Pinia: + +```ts no-run +export const useCartStore = defineStore({ + id: "cart", + state: () => ({ + rawItems: [] as string[], + }), + getters: { + items: (state): Array<{ name: string; amount: number }> => + state.rawItems.reduce( + (items, item) => { + const existingItem = items.find((it) => it.name === item); + + if (!existingItem) { + items.push({ name: item, amount: 1 }); + } else { + existingItem.amount++; + } + + return items; + }, + [] as Array<{ name: string; amount: number }>, + ), + }, + actions: { + addItem(name: string) { + this.rawItems.push(name); + }, + + removeItem(name: string) { + const i = this.rawItems.lastIndexOf(name); + if (i > -1) this.rawItems.splice(i, 1); + }, + + async purchaseItems() { + const user = useUserStore(); + if (!user.name) return; + + console.log("Purchasing", this.items); + const n = this.items.length; + this.rawItems = []; + + return n; + }, + }, +}); +``` + +This paradigm and CRDTs are easily compatible: The state in the state management +libraries corresponds to CRDT types, and Action corresponds to a set of CRDT +operations. + +Thus, implementing UI state management through CRDTs does not require users to +change their habits. It also has many advanced features: + +- Make states automatically synchronizable / support real-time collaboration. +- Like Git, maintain a complete distributed editing history. +- It can store an extensively large editing history with a low memory footprint + and a compact encoding size. Below is an example. + +With this, you can effortlessly implement products with real-time / async +collaboration and time machine features. + +![Tracing a document with 360,000 operations using Loro](./loro-now-open-source/Untitled.gif) + + +
+ Time travel a document with 360,000+ operations using Loro. To load the + whole history and playback, it only takes 8.4MB in memory. And the entire + history only takes 361KB in storage. The editing trace is from{" "} +
+ +
.
+ + +## Introduction to Loro + +Loro is our CRDTs library, now open-sourced under a permissive license. We +believe a cooperative and friendly open-source community is key to creating +outstanding developer experiences. + +We aim to make Loro simple to use, extensible, and maintain high performance. +The following is the latest status of Loro. + +### CRDTs + +We have explored extensively, supporting a range of CRDT algorithms that have +yet to be widely used. + +#### OT-like CRDTs + +> Update: This algorithm is now called Event Graph Walker (Eg-Walker) + +Our CRDTs library is built on the brilliant concept of OT-like CRDTs from Seph +Gentle's [Diamond-types](https://github.com/josephg/diamond-types). Joseph Gentle +and Martin Kleppmann later published the +[Eg-walker paper](https://arxiv.org/abs/2409.14252) on this line of work. Its +notable features include reducing the cost of local operations, easier +historical data reclamation, and sometimes lower storage and memory overhead. +However, it relies on high-performance algorithms to apply remote operations. +This design has great potential and we are excited about its future. + +
+Brief Introduction to OT-like CRDT algorithms + +To briefly introduce the concept of OT-like CRDTs, this part is complex and +requires some prior knowledge. I might not encapsulate it well. + +The general idea of OT-like CRDTs is that they do not retain the CRDTs' data +structure (e.g., originLeft originRight information). When merging remote +operations, they return to the lowest common ancestor in the directed acyclic +graph history of local and remote, and from there, reapply each operation. This +process reconstructs the CRDTs structure, resolving conflicts arising from +parallel editing. Its advantage is that, since it doesn't need to retain these +CRDTs Meta information, local operations are virtually cost-free, like OT, where +only the index at which insertions and deletions occur needs to be saved. The +trade-off is a longer time for merging remote operations, but this issue can be +significantly mitigated with well-designed data structures and algorithms. +Moreover, since most parallel edits last only a short time, the lowest common +ancestor is not far, making the merging process quick. + +The image below shows an example of merging versions 2@1 and 1@2 using this +algorithm on a DAG. The algorithm needs to revert to the lowest common ancestor +version 0@1 and apply all subsequent operations from there (a total of four +operations). For a better understanding of this image, refer to +[https://www.loro.dev/docs/advanced/version_deep_dive](https://www.loro.dev/docs/advanced/version_deep_dive) + +![Untitled](./loro-now-open-source/Untitled%201.png) + +
+ +#### Rich Text CRDTs + +In May of this year, we open-sourced the +[crdt-richtext](https://github.com/loro-dev/crdt-richtext) project, integrating +the algorithms of [the rich text CRDT](https://loro.dev/blog/loro-richtext) and the +sequence CRDT [Fugue by Matthew Weidner](https://arxiv.org/abs/2305.00583). A +brief introduction to these two algorithms can be found in +[our blog at the time](https://www.notion.so/crdt-richtext-Rust-implementation-of-Peritext-and-Fugue-c49ef2a411c0404196170ac8daf066c0?pvs=21). + +Based on our experience from previous projects, we have integrated a rich text +CRDT and Fugue into our framework in the current Loro. However, the biggest +challenge was that +[Peritext did not integrate well with OT-like CRDTs](https://github.com/inkandswitch/peritext/issues/31). +We have recently overcome this issue. We developed a new rich text CRDT +algorithm that can run on OT-like CRDTs and has passed the capabilities listed +in the Peritext paper's Criteria for rich text CRDTs, with no new issues +revealed in our current million fuzzing tests. We later introduced this work in +the [Loro rich text article](https://loro.dev/blog/loro-richtext). + +#### Movable Tree + +We have also supported a movable tree CRDT. Synchronizing tree movements is +often complex due to the potential for circular references. Addressing this +issue in the distributed environment is even more challenging. + +We implemented Martin Kleppmann's paper, +[_A Highly-Available Move Operation for Replicated Trees_](https://ieeexplore.ieee.org/document/9563274/). +The idea of this algorithm is to sort all move operations, ensuring the ordering +is consistent across the replicas. Then, each operation is applied sequentially. +If an operation would cause a circular reference, it has no effect. + +We found it to be elegant in design and also performant. The time complexity of +local operations is O(k) (k being the average tree depth, as circular reference +detection is required). For applying remote operations, which entails inserting +new operations into the sorted list, we must undo operations that are subsequent +in the ordering, apply the remote operation, and then redo the undone +operations, with a cost of O(km) (m being the number of operations to undo). + +![Untitled](./loro-now-open-source/Untitled%201.gif) + +Visualization of applying a remote op + +Our tests show that local operations involving ten thousand random movements +among a thousand nodes take less than 10ms (tested on an M2 MAX chip). Moreover, +the cost of merging remote operations in this algorithm is similar to applying +remote operations in OT-like CRDTs, making it adoptable. We've also experimented +with [log-spaced snapshots](https://madebyevan.com/algos/log-spaced-snapshots/) +and immutable data structure approaches in our +[movable-tree project](https://github.com/loro-dev/movable-tree), concluding +that the undo + redo method is the fastest and the most memory-efficient. + +### Data Structures + +Designing and experimenting with data structures is routine in Loro's +development process. + +We previously open-sourced +[generic-btree](https://github.com/loro-dev/generic-btree) and have redesigned +its structure for a more compact memory layout and cache-friendliness. Besides +its remarkable performance, its flexibility enables us to support various +information types required for Text, like utf16/Unicode code points/utf8, with +minimal code. We also extensively reuse it to fulfill various requirements, +highlighting Rust's impressive type expression capabilities. + +Internally, we've +[separated the document's state from its history](https://www.loro.dev/docs/advanced/doc_state_and_oplog). +The state represents the current form of the document, akin to Git's HEAD +pointer, while the document's history resembles the complete operation history +behind Git. Hence, multiple document states can correspond to the same history. +This structure simplifies our code and provides the foundation for Loro's +version-control primitives. + +Most of our optimizations thus far have focused on text manipulations, +historically one of the thorniest problems in CRDTs. Later releases expanded +these optimizations across more real-world scenarios. + +### Update + +![Untitled](./loro-now-open-source/Untitled%202.png) + +Loro has since reached 1.0 and stabilized its core data format. The project now +ships production-ready JavaScript/WASM and Rust packages, plus maintained +bindings and examples for other platforms. + +Documentation and developer tooling remain active areas of work. We welcome +ideas and suggestions in our +[community discussions](https://discord.gg/tUsBSVfqzf). + +Developing tools for developers is a challenging and exciting task. Many +developer tools and visualization methods in front-end development are +exceptionally good, and we hope to bring such experiences into the world of +CRDTs and local-first development. DevTools will reveal CRDTs' hidden states and +simplify control, making state maintenance and debugging a breeze. + +Loro now supports richer CRDT semantics, including movable data structures and +undo/redo APIs for more diverse application scenarios. + +## Seeking Collaborative Project Opportunities + +Our design and optimization efforts need feedback from real-world applications. +If you are excited about a local-first future and think Loro can help you, +please contact us directly at [zx@loro.dev](mailto:zx@loro.dev). We're open to +collaboration and ready to help. + + +# FILE: pages/blog/movable-tree.mdx + +--- +title: "Movable tree CRDTs and Loro's implementation" +date: 2024/07/18 +description: This article introduces the implementation difficulties and challenges of Movable Tree CRDTs when collaboration, and how Loro implements it and sorts child nodes. The algorithm has high performance and can be used in production. +image: https://i.ibb.co/nMrgzZJ/DALL-E-2024-01-31-21-29-16-Create-a-black-and-white-illustration-with-a-black-background-that-matche.png +--- + +# Movable tree CRDTs and Loro's implementation + +import Caption from "../../components/caption"; +import Authors, { Author } from "../../components/authors"; + + + + + +![](./movable-tree/movable-tree-cover.png) + +This article introduces the implementation difficulties and challenges of Movable Tree CRDTs when collaboration, and how Loro implements it and sorts child nodes. The algorithm has high performance and can be used in production. + +## Background + +In distributed systems and collaborative software, managing hierarchical relationships is difficult and complex. Challenges arise in resolving conflicts and meeting user expectations when working with the data structure that models movement by combining deletion and insertion. For instance, if a node is concurrently moved to different parents in replicas, it may lead to the unintended creation of duplicate nodes with the same content. Because the node is deleted twice and created under two parents. + +Currently, many software solutions offer different levels of support and functionality for managing hierarchical data structures in distributed environments. The key variation among these solutions lies in their approaches to handling potential conflicts. + +### Conflicts in Movable Trees + +A movable tree has 3 primary operations: creation, deletion, and movement. Consider a scenario where two peers independently execute various operations on their respective replicas of the same movable tree. Synchronizing these operations can lead to potential conflicts, such as: + +- The same node was deleted and moved +- The same node was moved under different nodes +- Different nodes were moved, resulting in a cycle +- The ancestor node is deleted while the descendant node is moved + +#### Deletion and Movement of the Same Node + +![Deletion and Movement of the Same Node](./movable-tree/move-delete-dark.png) + +This situation is relatively easy to resolve. It can be addressed by applying one of the operations while ignoring the other based on the timestamp in the distributed system or the application's specific requirements. Either approach yields an acceptable outcome. + +#### Moving the Same Node Under Different Parents + +![Moving the Same Node Under Different Parents](./movable-tree/move-same-node-dark.png) + +Merging concurrent movement operations of the same node is slightly more complex. Different approaches can be adopted depending on the application: + +- Delete the node and create copies of nodes under different parent nodes. Subsequent operations then treat these nodes independently. This approach is acceptable when node uniqueness is not critical. +- Allow the node have two edges pointing to different parents. However, this approach breaks the fundamental tree structure and is generally not considered acceptable. +- Sort all operations, then apply them one by one. The order can be determined by timestamps in a distributed system. Providing the system maintains a consistent operation sequence, it ensures uniform results across all peers. + +#### Movement of Different Nodes Resulting in a Cycle + +![cycle](./movable-tree/cycle-dark.png) + +Concurrent movement operations that cause cycles make the conflict resolution of movable trees complex. Matthew Weidner listed several solutions to resolve cycles in his [blog](https://mattweidner.com/2023/09/26/crdt-survey-2.html#forests-and-trees). + +> 1. Error. Some desktop file sync apps do this in practice ([Martin Kleppmann et al. (2022)](https://doi.org/10.1109/TPDS.2021.3118603) give an example). +> 2. Render the cycle nodes (and their descendants) in a special “time-out” zone. They will stay there until some user manually fixes the cycle. +> 3. Use a server to process move ops. When the server receives an op, if it would create a cycle in the server’s own state, the server rejects it and tells users to do likewise. This is [what Figma does](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects). Users can still process move ops optimistically, but they are tentative until confirmed by the server. (Optimistic updates can cause temporary cycles for users; in that case, Figma uses strategy (2): it hides the cycle nodes.) +> 4. Similar, but use a [topological sort](https://mattweidner.com/2023/09/26/crdt-survey-2.html#topological-sort) (below) instead of a server’s receipt order. When processing ops in the sort order, if an op would create a cycle, skip it [(Martin Kleppmann et al. 2022)](https://doi.org/10.1109/TPDS.2021.3118603). +> 5. For forests: Within each cycle, let `B.parent = A` be the edge whose `set` operation has the largest LWW timestamp. At render time, “hide” that edge, instead rendering `B.parent = "none"`, but don’t change the actual CRDT state. This hides one of the concurrent edges that created the cycle. +> • To prevent future surprises, users’ apps should follow the rule: before performing any operation that would create or destroy a cycle involving a hidden edge, first “affirm” that hidden edge, by performing an op that sets `B.parent = "none"`. +> 6. For trees: Similar, except instead of rendering `B.parent = "none"`, render the previous parent for `B` - as if the bad operation never happened. More generally, you might have to backtrack several operations. Both [Hall et al. (2018)](http://dx.doi.org/10.1145/3209280.3229110) and [Nair et al. (2022)](https://arxiv.org/abs/2103.04828) describe strategies along these lines. + +#### Ancestor Node Deletion and Descendant Node Movement + +![Ancestor Node Deletion and Descendant Node Movement](./movable-tree/move_chlid_delete_parent_dark.png) + +The most easily overlooked scenario is moving descendant nodes when deleting an ancestor node. If all descendant nodes of the ancestor are deleted directly, users may easily misunderstand that their data has been lost. + +### How Popular Applications Handle Conflicts + +Dropbox is a file data synchronization software. Initially, Dropbox treated file movement as a two-step process: deletion from the original location followed by creation at a new location. However, this method risked data loss, especially if a power outage or system crash occurred between the delete and create operations. + +Today, when multiple people move the same file concurrently and attempt to save their changes, Dropbox detects a conflict. In this scenario, it typically saves one version of the original file and creates a new ["conflicted copy"](https://help.dropbox.com/organize/conflicted-copy) for the changes made by one of the users. + +![Solution for conflicts when moving files with Dropbox](./movable-tree/dropbox_move.gif) + + + The image shows the conflict that occurs when A is moved to the B folder and B + is moved to the A folder concurrently. + + +Figma is a real-time collaborative prototyping tool. They consider tree structures as the most complex part of the collaborative system, as detailed in their [blog post about multiplayer technology](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects). To maintain consistency, each element in Figma has a "parent" attribute. The centralized server plays a crucial role in ensuring the integrity of these structures. It monitors updates from various users and checks if any operation would result in a cycle. If a potential cycle is detected, the server rejects the operation. + +However, due to network delays and similar issues, there can be instances where updates from users temporarily create a cycle before the server has the chance to reject them. Figma acknowledges that this situation is uncommon. Their [solution](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects) is straightforward yet effective: they temporarily preserve this state and hide the elements involved in the cycle. This approach lasts until the server formally rejects the operation, ensuring both the stability of the system and a seamless user experience. + +
+ ![An animation that demonstrates how Figma resolves + conflicts.](./movable-tree/figma-tree.gif) +
+ + + An animation that demonstrates how + [Figma](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects) + resolves conflicts. + + +## Movable Tree CRDTs + +The applications mentioned above use movable trees and resolve conflicts based on centralized solutions. Another alternative approach to collaborative tree structures is using Conflict-free Replicated Data Types (CRDTs). While initial CRDT-based algorithms were challenging to implement and incurred significant storage overhead as noted in prior research, such as [Abstract unordered and +ordered trees CRDT](https://arxiv.org/pdf/1201.1784.pdf) or [File system on CRDT](https://arxiv.org/pdf/1207.5990.pdf), but continual optimization and improvement have made several CRDT-based tree synchronization algorithms suitable for certain production environments. This article highlights two innovative CRDT-based approaches for movable trees. The first is presented by Martin Kleppmann et al. in their work **_[A highly-available move operation for replicated trees](https://martin.kleppmann.com/2021/10/07/crdt-tree-move-operation.html)_** and the second by Evan Wallace in his **_[CRDT: Mutable Tree Hierarchy](https://madebyevan.com/algos/crdt-mutable-tree-hierarchy/)_**. + +### A highly-available move operation for replicated trees + +This paper unifies the three operations used in trees (creating, deleting, and moving nodes) into a move operation. The move operation is defined as a four-tuple `Move t p m c`, where `t` is the operation's unique and ordered timestamp such as [`Lamport timestamp`](https://en.wikipedia.org/wiki/Lamport_timestamp), `p` is the parent node ID, `m` is the metadata associated with the node, and `c` is the child node ID. + +If all nodes of the tree do not contain `c`, this is a **creation** operation that creates a child node `c` under parent node `p`. Otherwise, it is a **move** operation that moves `c` from its original parent to the new parent `p`. Additionally, node deletion is elegantly handled by introducing a designated `TRASH` node; moving a node to `TRASH` implies its deletion, with all descendants of `TRASH` considered deleted. But they remain in memory to prevent concurrent editing from moving them to other nodes. In order to handle the previously mentioned situation of deleting ancestor nodes and moving descendant nodes concurrently. + +In the three potential conflicts mentioned earlier, since deletion is also defined as a move operation, **deleting and moving the same node** is transformed into two move operations, leaving only two remaining problems: + +- **Moving the same node under different parents** +- **Moving different nodes, creating a cycle** + +Logical timestamps are added so that all operations can be linearly ordered, thus the first conflict can be avoided as they can be expressed as two operations in sequence rather than concurrently for the same node. Therefore, in modeling a Tree using only move operations, the only exceptional case in concurrent editing would be creating a cycle, and operations causing a cycle are termed **unsafe operations**. + +This algorithm sorts all move operations according to their timestamps. It can then sequentially apply each operation. Before applying, the algorithm detects cycles to determine whether an operation is safe. If the operation creates a cycle, we ignore the unsafe operation to ensure the correct structure of the tree. + + +Based on the above approach, the consistency problem of movable trees becomes the following two questions: + +1. How to introduce global order to operations +2. How to apply a remote operation that should be inserted in the middle of an existing sorted sequence of operations + +#### Globally Ordered Logical Timestamps + +[Lamport Timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp) can determine the causal order of events in a distributed system. Here's how they work: each peer starts with a counter initialized to `0`. When a local event occurs, the counter is increased by `1`, and this value becomes the event's Lamport Timestamp. When peer `A` sends a message to peer `B`, `A` attaches its Lamport Timestamp to the message. Upon receiving the message, peer `B` compares its current logical clock value with the timestamp in the message and updates its logical clock to the larger value. + +To globally sort events, we first look at the Lamport Timestamps: smaller numbers mean earlier events. If two events have the same timestamp, we use the unique ID of the peer serves as a tiebreaker. + + +#### Apply a Remote Operation + +An op's safety depends on the tree's state when applied, avoiding cycles. Insertion requires evaluating the state formed by all preceding ops. For remote updates, we may need to: + +1. Undo recent ops +2. Insert the new op +3. Reapply undone ops + +This ensures proper integration of new ops into the existing sequence. + +##### Undo Recent Ops + +Since we've modeled all operations on the tree as move operations, undoing a move operation involves either moving the node back to its old parent or undoing the operation that created this node. To enable quick undoing, we cache and record the **old parent** of the node before applying each move operation. + +##### Apply the Remote Op + +Upon encountering an unsafe operation, disregarding its effects prevents the creation of a cycle. Nevertheless, it's essential to record the operation, as the safety of an operation is determined **dynamically**. For instance, if we receive and sort an update that deletes another node causing the cycle prior to this operation, the operation that was initially unsafe becomes safe. Additionally, we need to mark this unsafe operation as ineffective, since during undo operations, it's necessary to query the **old parent** node, which is the target parent of the last effective operation in the sequence targeting this node. + +##### Reapply Undone Ops + +Cycles only occur when receiving updates from other peers, so the undo-do-redo process is also needed at this time. When receiving a new op: + +```jsx +function apply(newOp) + // Compare the ID of the new operation with existing operations + if largerThanExistingOpId(newOp.id, oplog) + // If the new operation's ID is greater, apply it directly + oplog.applyOp(newOp) + else + // If the new operation's ID is not the greatest, undo operations until it can be applied + undoneOps = oplog.undoUtilCanBeApplied(newOp) + oplog.applyOp(newOp) + // After applying the new operation, redo the undone operations to maintain sequence order + oplog.redoOps(undoneOps) +``` + +- If the new operation depends on an op that has not been encountered locally, indicating that some inter-version updates are still missing, it is necessary to temporarily cache the new op and wait to apply it until the missing updates are received. +- Compare the new operation with all existing operations. If the `opId` of the new operation is greater than that of all existing operations, it can be directly applied. If the new operation is safe, record the parent node of the target node as the old parent node, then apply the move operation to change the current state. If it is not safe, mark this operation as ineffective and ignore the operation's impact. +- If the new opId is sorted in the middle of the existing sequence, it is necessary to pop the operations that are sorted later from the sequence one by one, and undo the impact of this operation, which means moving back to the child of the old parent node, until the new operation can be applied. After applying the new operation, reapply the undone nodes in sequence order, ensuring that all operations are applied in order. + +The following animated GIF demonstrates the process executed by `Peer1`: + +1. Received `Peer0` creating node `A` with the `root` node as its parent. +2. Received `Peer0` creating node `B` with `A` as its parent. +3. Created node `C` with `A` as its parent and synchronized it with `Peer0`. +4. Moved `C` to have `B` as its parent. +5. Received `Peer0`'s moving `B` to have `C` as its parent. + +
+ ![](./movable-tree/undo-do-redo.gif) +
+ +The queue at the top right of the animation represents the order of local operations and newly received updates. The interpretation of each element in each `Block` is as follows: + +
+ ![](./movable-tree/explain.png) +
+ +A particular part of this process to note is the two operations with `lamport timestamps` of `0:3` and `1:3`. Initially, the `1:3` operation moving `C` to `B` was created and applied locally, followed by receiving `Peer0`'s `0:3` operation moving `B` to `C`. In `lamport timestamp` order, `0:3` is less than `1:3` but greater than `1:2` (with peer as the tiebreaker when counters are equal). To apply the new op, the `1:3` operation is undone first, moving `C` back to its old parent `A`, then `0:3` moving `B` to `C` is applied. After that, `1:3` is redone, attempting to move `C` to `B` again (the old parent remains `A`, omitted in the animation). However, a cycle is detected during this attempt, preventing the operation from taking effect, and the state of the tree remains unchanged. This completes an `undo-do-redo` process. + +### CRDT: Mutable Tree Hierarchy + +Evan Wallace has developed an innovative algorithm that enables each node to track all its historical parent nodes, attaching a counter to each recorded parent. The count value of a new parent node is 1 higher than that of all the node's historical parents, indicating the update sequence of the node's parents. The parent with the highest count is considered the current parent node. + +During synchronization, this parent node information is also synced. If a cycle occurs, a heuristic algorithm reattaches the nodes causing the cycle back to the nearest historical parent node that won't cause a cycle and is connected to the root node, thus updating the parent node record. This process is repeated until all nodes causing cycles are reattached to the tree, achieving all replica synchronization of the tree structure. The demo in [Evan's blog](https://madebyevan.com/algos/crdt-mutable-tree-hierarchy/) clearly illustrates this process. + +As Evan summarized at the end of the article, this algorithm does not require the expensive `undo-do-redo` process. However, each time a remote move is received, the algorithm needs to determine if all nodes are connected to the root node and reattach the nodes causing cycles back to the tree, which can perform poorly when there are too many nodes. + +I established a [benchmark](https://github.com/Leeeon233/movable-tree-crdt) to compare the performance of the movable tree algorithms. + +## Movable Tree CRDTs implementation in Loro + +Loro implements the algorithm proposed by Martin Kleppmann et al., **_[A highly-available move operation for replicated trees](https://martin.kleppmann.com/2021/10/07/crdt-tree-move-operation.html)_**. On one hand, this algorithm has high performance in most real world scenarios. On the other hand, the core `undo-do-redo` process of the algorithm is highly similar to how Eg-walker (Event Graph Walker) applies remote updates in Loro. Introduction about **Eg-walker** can be found in our previous [blog](https://www.loro.dev/blog/loro-richtext#brief-introduction-to-replayable-event-graph). + +Movable tree has been introduced in detail, but there is still another problem of tree structure that has not been solved. For movable tree, in some real use cases, we still need the capability to sort child nodes. This is necessary for outline notes or layer management in graphic design softwares. Users need to adjust node order and sync it to other collaborators or devices. + +We integrated the `Fractional Index` algorithm into Loro and combined it with the movable tree, making the child nodes of the movable tree sortable. + +There are many introductions to `Fractional Index` on the web, You can read more about `Fractional Index` in the [Figma blog](https://www.figma.com/blog/realtime-editing-of-ordered-sequences) or [Evan blog](https://madebyevan.com/algos/crdt-fractional-indexing/). In simple terms, `Fractional Index` assigns a sortable value to each object, and if a new insertion occurs between two objects, the `Fractional Index` of the new object will be between the left and right values. What we want to speak about more here is how to deal with potential conflicts brought by `Fractional Index` in CRDTs systems. + +### Potential Conflicts in Child Node Sorting + +As our applications are in a distributive condition, when multiple peers insert new nodes in the same position, the same `Fractional Index` would be assigned to these differing content but same position nodes. When updates from the remote are applied to local, conflicts arise as the same `Fractional Index` is encountered. + +In Loro, we retain these identical `Fractional Index` and use `PeerID` (unique ID of every Peer) as the tie-breaker for the relative order judgment of the same `Fractional Index`. + +![](./movable-tree/FI-and-PeerID-dark.png) + +Although this solved the sorting problem among the same `Fractional Index` nodes from different peers, it impacted the generation of new `Fractional Index` as we cannot generate a new `Fractional Index` between two same ones. We use two methods to solve this problem: + +1. The first method, as stated in Evan's blog, we could add a certain amount of jitter to each generated `Fractional Index`, (for the ease of explanation, all examples below take decimal fraction as the `Fractional Index`) for example, when generating a new `Fractional Index` between 0 and 1, it should have been 0.5, but through random jitters, it could be `0.52712`, `0.58312`, `0.52834`, etc., thus significantly reducing the chance of same `Fractional Index` appearing. +2. If the situation arises where the same `Fractional Index` is present on both sides, we can handle this problem by resetting these `Fractional Index`. For example, if we need to insert a new node between `0.7@A` and `0.7@B` (which indicates `Fractional Index` @ `PeerID`), instead of generating a new `Fractional Index` between 0.7 and 0.7, we could assign two new `Fractional Index` respectively for the new node and the `0.7@B` node between 0.7 and 1, which could be understood as an extra move operations. + +![](./movable-tree/same-FI-dark.png) + +### Implementation and Encoding Size + +Introducing `Fractional Index` brings the advantage of node sequence. What about encoding size? + +Loro uses [drifting-in-space](https://github.com/drifting-in-space/fractional_index) `Fractional Index` implementation based on `Vec`, which is base 256. In other words, you need to continuously insert 128 values forward or backward from the default value to increase the byte size of the `Fractional Index` by 1. The worst storage overhead case, such as inserting new values alternately each time. For example, the initial sequence is `ab`, insert `c` between `a` and `b`, then insert `d` between `c` and `b`, then `e` between `c` and `d`, like: + +```js no_run +ab // [128] [129, 128] +acb // [128] [129, 127, 128] [129, 128] +acdb // [128] [129, 127, 128] [129, 127, 129, 128] [129, 128] +acedb // [128] [129, 127, 128] [129, 127, 129, 127, 128] [129, 127, 129, 128] [129, 128] +``` +a new operation would cause an additional byte to be needed. But such a situation is very rare. + +Considering that potential conflicts wouldn't appear frequently in most applications, Loro simply extended the implementation, the original implementation produced new `Fractional Index` in `Vec` by only increasing or decreasing 1 in certain index to achieve relative sorting. The simple jitter solution was added, by appending random bytes in length of jitter value to `Fractional Index`. To enable jitter in js, you can use `doc.setFractionalIndexJitter(number)` with a positive value. But this will increase the encoding size slightly, but each `Fractional Index` only adds `jitter` bytes. If you want to generate `Fractional Index` at the same position with 99% probability without conflict, the relationship between `jitter` settings and the maximum number of concurrent edits `n` will be: + + + + + + + + + + + + + + + + + + + + + + +
+ jitter + + max num of concurrent edits +
+ 1 + + 3 +
+ 2 + + 37 +
+ 3 + + 582 +
+ +When there are numerous `Fractional Indexes`, there will be many common prefixes after being sorted, when Loro encodes these `Fractional Indexes`, prefix optimization would be implemented. Each `Fractional Index` only saves the amount of same prefix bits and remaining bytes with the previous one, which further downsizes the overall encoding size. + +### Related work + +Other than using Fractional Index, there are other movable list CRDT that can make sibling nodes of the tree in order. One of these algorithms is Martin Kleppmann's [Moving Elements in List CRDTs](https://martin.kleppmann.com/2020/04/27/papoc-list-move.html), which has been used in Loro's [Movable List](https://www.loro.dev/docs/tutorial/list). + + +In comparison, the implementation of `Fractional Index` solution is simpler, and no stable position representation is provided for child nodes when modeling nodes in a tree, otherwise, the overall tree structure would be too complex. However, the `Fractional Index` has the problem of [interleaving](https://vlcn.io/blog/fractional-indexing#interleaving), but this is acceptable when some only need relative order and do not require strict sequential semantics, such as figma layer items, multi-level bookmarks, etc. + +## Benchmark + +We conducted performance benchmarks on the Movable Tree implementation by Loro, including scenarios of random node movement, switching to historical versions, and performance under extreme conditions with significantly deep tree structures. The results indicate that it is capable of supporting real-time collaboration and enabling seamless historical version checkouts. + +| Task | Time | Setup | +| :---------------------------------------- | :----- | :------------------------------------------------ | +| Move 10000 times randomly | 28 ms | Create 1000 nodes first | +| Switch to different versions 1000 times | 153 ms | Create 1000 nodes and move 1000 times first | +| Switch to different versions 1000 times in a tree with depth of 300 | 701 ms | The new node is a child node of the previous node | + + + Test environment: M2 Max CPU, you can find the bench code + [here](https://github.com/loro-dev/loro/blob/main/crates/loro-internal/benches/tree.rs). + + +## Usage + +```tsx +import { Loro, LoroTree, LoroTreeNode, LoroMap } from "loro-crdt"; + +let doc = new Loro(); +let tree: LoroTree = doc.getTree("tree"); +let root: LoroTreeNode = tree.createNode(); +// By default, append to the end of the parent node's children list +let node = root.createNode(); +// Specify the child's position +let node2 = root.createNode(0); +// Move `node2` to be the last child of `node` +node2.move(node); +// Move `node` to be the first child of `node2` +node.move(node2, 0); +// Move the node to become the root node +node.move(); +// Move the node to be positioned after another node +node.moveAfter(node2); +// Move the node to be positioned before another node +node.moveBefore(node2); +// Retrieve the index of the node within its parent's children +let index = node.index(); +// Get the `Fractional Index` of the node +let fractionalIndex = node.fractionalIndex(); +// Access the associated data map container +let nodeData: LoroMap = node.data; +``` + +### Demo + +We developed a simulated Todo app with data synchronization among multiple peers using Loro, including the use of `Movable Tree` to represent subtask relationships, `Map` to represent various attributes of tasks, and `Text` to represent task titles, etc. In addition to basic creation, moving, modification, and deletion, we also implemented version switching based on Loro. You can drag the scrollbar to switch between all the historical versions that have been operated on. + +