From af4bd0b872a55d2984eed4153c12b0c155f9d364 Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 22 Sep 2026 17:31:24 +0100 Subject: [PATCH] drafts less docsy --- drafts/package.json | 10 +- drafts/pnpm-lock.yaml | 403 +++++++++------------ drafts/src/DraftsSidebar.tsx | 9 +- drafts/src/actor-attribution.ts | 104 ++++-- drafts/src/change-group-cache.test.ts | 112 ++++++ drafts/src/change-group-cache.ts | 325 +++++++++-------- drafts/src/providers/DraftStateProvider.ts | 292 ++++++++++----- 7 files changed, 746 insertions(+), 509 deletions(-) create mode 100644 drafts/src/change-group-cache.test.ts diff --git a/drafts/package.json b/drafts/package.json index 4e05342f..1e0256e3 100644 --- a/drafts/package.json +++ b/drafts/package.json @@ -15,16 +15,16 @@ "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", "@automerge/automerge-repo": "2.6.0-subduction.47", - "@inkandswitch/patchwork-elements": "1.0.0", - "@inkandswitch/patchwork-plugins": "^0.0.11", - "@inkandswitch/patchwork-providers": "0.5.1", - "@inkandswitch/patchwork-providers-solid": "0.2.7", + "@inkandswitch/patchwork-elements": "^6.0.2", + "@inkandswitch/patchwork-plugins": "^1.2.5", + "@inkandswitch/patchwork-providers": "^0.5.2", + "@inkandswitch/patchwork-providers-solid": "^0.2.8", "solid-automerge": "^2.0.1", "solid-js": "^1.9.14" }, "devDependencies": { "@eslint/js": "^9.39.5", - "@inkandswitch/patchwork-bootloader": "^0.2.8", + "@inkandswitch/patchwork-bootloader": "^0.7.2", "@typescript-eslint/parser": "^8.66.0", "eslint": "^9.39.5", "globals": "^16.5.0", diff --git a/drafts/pnpm-lock.yaml b/drafts/pnpm-lock.yaml index a70b676d..8cd2de47 100644 --- a/drafts/pnpm-lock.yaml +++ b/drafts/pnpm-lock.yaml @@ -15,17 +15,17 @@ importers: specifier: 2.6.0-subduction.47 version: 2.6.0-subduction.47(supports-color@7.2.0) '@inkandswitch/patchwork-elements': - specifier: 1.0.0 - version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0) + specifier: ^6.0.2 + version: 6.0.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0) '@inkandswitch/patchwork-plugins': - specifier: ^0.0.11 - version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + specifier: ^1.2.5 + version: 1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) '@inkandswitch/patchwork-providers': - specifier: 0.5.1 - version: 0.5.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) + specifier: ^0.5.2 + version: 0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) '@inkandswitch/patchwork-providers-solid': - specifier: 0.2.7 - version: 0.2.7(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0))(solid-js@1.9.14) + specifier: ^0.2.8 + version: 0.2.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0))(solid-js@1.9.14) solid-automerge: specifier: ^2.0.1 version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0) @@ -37,8 +37,8 @@ importers: specifier: ^9.39.5 version: 9.39.5 '@inkandswitch/patchwork-bootloader': - specifier: ^0.2.8 - version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0) + specifier: ^0.7.2 + version: 0.7.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) '@typescript-eslint/parser': specifier: ^8.66.0 version: 8.66.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) @@ -69,55 +69,39 @@ importers: packages: - '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c': - resolution: {integrity: sha512-VM1tL9G+qQZ+0s0CM7DxBFkldwv5/GwqwZsW9U33qIU4LJES4MLc8QNeTtg9EJ7n7Pv0aovghRBaro2RdQHk8g==} - - '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.29': - resolution: {integrity: sha512-2NnV1U6l0jNWUpRoL24p8j2/G+CsVPRQfqZDQwZXderrdo2FkaZxVAIM8HXd96xLbIV0HiMsTMGWDo1nQdVTZA==} - - '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.29': - resolution: {integrity: sha512-PhEmtKzD+2s5kPf7divNJl0NjuCgJ9IRPKB5ZoqMCJOn0+6ZKAClQAqZ+rJKbI+XmjaJLGIKprnQ4HIx/vnW9Q==} - - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.21': - resolution: {integrity: sha512-9advk34JgRvBRLtSnMIrP4jhkC6eKB2bbH+v/q+zsRLkk3vrdTxQCWcuh1LqdbF3U8DEZmIjU9M7pYumS9Mwaw==} - - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.29': - resolution: {integrity: sha512-M44FxVH01ihg2bLhwuEqnzkLd69I5LRROvRsS2Nf/7kvo4Smxkwr38ATKRc9cEzIF9Hla++6Gsf4Jj7vFgpzug==} + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': + resolution: {integrity: sha512-fyq/ZqWkrYOuNrQ917KeCaRSuY6fzqH6Q4KCb5czjwKPKO2zFIGKVfK5hZjYsHGWRKuh5BzViaONHn6bJ0Jk4w==} + engines: {node: '>=22.13'} - '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.29': - resolution: {integrity: sha512-Tzr3n3rfUFq2rOgc0HeIDm1dorUBHkg0DUl7qXqMa9iyincSOCdFfyr8GQBGe9rDIWYfhy8QSccwY8gEqflrWg==} + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': + resolution: {integrity: sha512-pf+cmCi/TWQZpqrm+iinVpIM2Ecfzp8JpGEo28SPbE2Q8+eLyO2cz1Dssnuep1+KQ8k7Dfx7aUF9AD7/6jAvTQ==} + engines: {node: '>=22.13'} - '@automerge/automerge-repo@2.6.0-subduction.21': - resolution: {integrity: sha512-MG9v9c7z0zrGmXpdwkeuSiOcdAO+vmPp857WoJrECBG6au0uKJqXszndahLZYXUoolZw3TvByVAOe7Swosft+w==} + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': + resolution: {integrity: sha512-TRKAq4iTdrSpOQYxyKZMJDJ5Civa+vONrAHBZkpK+DGShzqBB6vCdTCsrBGBF2Q63H33qQM0XYBQEQKIa/zM+Q==} + engines: {node: '>=22.13'} - '@automerge/automerge-repo@2.6.0-subduction.29': - resolution: {integrity: sha512-tTx13I4egBCwDeVoSXG8y9+p7O1VDgY0erD67crBJek/oasme32+cchHimE7Tl4Zf5a+5mJ90qMRV5f3J48KcQ==} + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': + resolution: {integrity: sha512-7XIn/iGrAVS4ugva8m0bU1shhRtuy/fdCAgF6qaIEvbi7XQllLVn3miTTiusLaksNhnRtbMfDLUknazU+vVHew==} + engines: {node: '>=22.13'} '@automerge/automerge-repo@2.6.0-subduction.47': resolution: {integrity: sha512-NGBQUjGH67Kyrc8a6zoafvDKT1+pnFi2/yiMgULS7l+apWQT2QkgQZ3vQGms/ngdrmM6ye1fhcwHwBn/DGJ6tA==} engines: {node: '>=22.13'} - '@automerge/automerge-subduction@0.13.0': - resolution: {integrity: sha512-7XlCIS0GH3ctboE8UPmD9rfiv203Q8n5Lx63xE6xCgtlpwecDKqzLDp77e9hUEo76j17Y8bYQyfsROqaByLf5A==} - - '@automerge/automerge-subduction@0.15.0': - resolution: {integrity: sha512-UxD3hfzZoH9yj2/YQZZSr9bJH7R/l+xIIZ9nMPMpsBOAlEFI5AEC4hkVOqC52QOCwq4bcqek0gTYkSWWfNvc2A==} + '@automerge/automerge-repo@2.6.0-subduction.48': + resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==} + engines: {node: '>=22.13'} '@automerge/automerge-subduction@0.16.1': resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} - '@automerge/automerge@3.2.6': - resolution: {integrity: sha512-9/GXXfYYWNVGpnbRrGQzTNU4fWZ3XaEMeEg0OrpK4pvlQSpkmUBoirEb/4TMK6BwMysZGV5Yeneq3wwc7RNGfg==} - '@automerge/automerge@3.3.0-fragments.1': resolution: {integrity: sha512-Im2n3qcYSuIsLDSjofTRo7CZyyvFn5pKLSCCO5sjWzESw+9q9G25ZIrL2mczDX43cXtyzi+w/4b9VUaiNdY56Q==} '@automerge/automerge@3.3.2': resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} - '@automerge/vanillajs@2.6.0-subduction.29': - resolution: {integrity: sha512-Vbpz8D4PE8CqVtuTN/2D1XWM0ZhrpFD1zpmbeexn8FQestDR4bIWfp8rnfPFoqE6R/t6+96Rr9wTkZhVba0Bgw==} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -229,6 +213,18 @@ packages: cpu: [x64] os: [win32] + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/state@6.7.5': + resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} + + '@codemirror/view@6.43.12': + resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -443,39 +439,30 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inkandswitch/patchwork-bootloader@0.2.8': - resolution: {integrity: sha512-jXH5Q2JF4zoyR3nw0JYTi8yLfXfFC0PsvDKVXs+Ko48lZb9NRTWphRrvrtoM+AoDVEx/lgBEHjzA6uKqHlj8AA==} + '@inkandswitch/patchwork-bootloader@0.7.2': + resolution: {integrity: sha512-b5ki9fodXB7uEzvBvpZ0uAkaM7CwwtAdD596XKZJY+TjaSnYsWnx0IP0ewz9rL6ASU282ItyCB8917PKxr8ckA==} peerDependencies: - '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.29 - '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c - '@automerge/vanillajs': 2.6.0-subduction.29 - - '@inkandswitch/patchwork-elements@1.0.0': - resolution: {integrity: sha512-DoHfqmUPEAcdR7rlPVMi5Lon/AsitwY1uLdA1Pg+KOVbXsjKwrvPJIqkVo2F4uDSccO1Rluwau2cjT5Rg7w5IQ==} - peerDependencies: - '@automerge/automerge-repo': '*' - '@inkandswitch/patchwork-filesystem': workspace:^ - '@inkandswitch/patchwork-plugins': workspace:^ - '@inkandswitch/patchwork-providers': workspace:^ - '@types/react': '*' - react: '*' - solid-js: '*' + '@automerge/automerge': 3.5.0 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo-keyhive': 0.5.0-alpha.7 + '@automerge/vanillajs': 2.6.0-subduction.48 peerDependenciesMeta: - '@types/react': + '@automerge/automerge': optional: true - react: + '@automerge/automerge-repo': optional: true - solid-js: + '@automerge/automerge-repo-keyhive': + optional: true + '@automerge/vanillajs': optional: true - '@inkandswitch/patchwork-elements@2.0.0': - resolution: {integrity: sha512-P1fF5IBwVQQwklGcInzW+SzYdMZ60Er5lfXlSLTc7E1QB3KDlpUrSC9wsBcqTG+OOEwZQ53G4MtTGrdfMqYFmw==} + '@inkandswitch/patchwork-elements@6.0.2': + resolution: {integrity: sha512-ZloPtyzYDEhDCY7uzESATppaYETTjIwQwtFqJUR3VACHl+oHNY3fqgdfpH6G0IIswQcTCtpJTCnhV0rCNNwc7Q==} peerDependencies: '@automerge/automerge-repo': '*' - '@inkandswitch/patchwork-filesystem': ^0.0.8 - '@inkandswitch/patchwork-plugins': ^0.0.11 - '@inkandswitch/patchwork-providers': ^0.3.0 + '@inkandswitch/patchwork-filesystem': ^0.2.8 + '@inkandswitch/patchwork-plugins': ^1.2.3 + '@inkandswitch/patchwork-providers': ^0.5.2 '@types/react': '*' react: '*' solid-js: '*' @@ -487,33 +474,28 @@ packages: solid-js: optional: true - '@inkandswitch/patchwork-filesystem@0.0.8': - resolution: {integrity: sha512-gfS7OHC2W1xG0EWl5yQMPQl+Ra/hLsgus9X0VmwHvsovFSR9tun7Clz3XcIyAqEU7VpEtCkGrxHkMgw54SJxlg==} + '@inkandswitch/patchwork-filesystem@0.2.9': + resolution: {integrity: sha512-To2zBNQoAC4BT+PwpZrc0aKZhjLOTd0poJJs/wYEP1wX3r9xr97Og9CdLEvsqsVnEIUXEdZvizH0fbmY0VA14w==} peerDependencies: '@automerge/automerge': '*' '@automerge/automerge-repo': '*' - '@inkandswitch/patchwork-plugins@0.0.11': - resolution: {integrity: sha512-ElwDEixpZN64gdoE7EU8QFz4WzvyJt6j4Zn0pTezsW6SzGlx+Rc6Ox+7POK7gqEzQEcl3Iqo8+QdYRucYllRFw==} + '@inkandswitch/patchwork-plugins@1.2.5': + resolution: {integrity: sha512-4R/N6W4v4Xyfp47RaG7WesN0jUdNsQmZshJt9pHfsTbAFEbvNvgnmC2JF9mzSL5CagCy1njACIoasOVvkv/A/A==} peerDependencies: '@automerge/automerge': '*' '@automerge/automerge-repo': '*' - '@inkandswitch/patchwork-filesystem': ^0.0.8 + '@inkandswitch/patchwork-filesystem': ^0.2.9 - '@inkandswitch/patchwork-providers-solid@0.2.7': - resolution: {integrity: sha512-QCs8UlXaQJNCee+bgypTMNdH4eIC7OT91yo+p1oBaVQfSaisWdcPKwz8i418eSgckT/tMJTKMUuv2X02iGHOEA==} + '@inkandswitch/patchwork-providers-solid@0.2.8': + resolution: {integrity: sha512-tyW35vk6/17d9QPAgKYjNCCpf/gATan5jIZLfvgKBB/trhQtoYywfIZaCzHsjeVbH/FXnWq/eDISO973r5je3A==} peerDependencies: '@automerge/automerge-repo': '*' solid-automerge: '*' solid-js: ^1.9.0 - '@inkandswitch/patchwork-providers@0.3.0': - resolution: {integrity: sha512-CUxWbONfOiz5SCzOpX/7zDTMePWgq7e4778eBJpbjC5Et4lnyL2/UrOZ6YJ4HiCG7r7MbfM86GUi63bqjTeutg==} - peerDependencies: - '@automerge/automerge-repo': '*' - - '@inkandswitch/patchwork-providers@0.5.1': - resolution: {integrity: sha512-KOZghTF4CVSK0jH9F/4JHtiACJsyQRluVCtfKrbwQPxQKhnuHIsbQHyBQu/Bsx9QMDFGdGcX7rs9jBj7qiQ6WQ==} + '@inkandswitch/patchwork-providers@0.5.2': + resolution: {integrity: sha512-jTR6aOPX4o5uj2OA45hcJ0A/K5fhQJPtEY+B2z9xaBWCgktZ/OLKWV2IQeQ2E98r++gu7BV/2f8tP+KwTLWJJA==} peerDependencies: '@automerge/automerge-repo': '*' @@ -533,11 +515,20 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@keyhive/keyhive@0.0.0-alpha.56': - resolution: {integrity: sha512-hx/MRkRqCSJFIh5jHKs9HkzDQy4tUZzgaRlc85+8t22VciubmZAU1FyugmYboskPUxUGwqZC1GarwXfxhrp5DA==} + '@keyhive/keyhive@0.1.0-alpha.8': + resolution: {integrity: sha512-juyDs15N3xyKl9mtGHoghdxiCGiXVReS4GufjUMATE3TsgASA/fznISCic+sKc9mMg+aAOXWFAbNELfBe3GyvA==} - '@keyhive/keyhive@0.0.0-alpha.57g': - resolution: {integrity: sha512-o8u0emy+vQQPmYKS8HqyjAUw9hXEApXv3tt+G9ZUJUPMVTuaor17N4BKFmNdAiH2Jt/TTX7Vsr8QaRNRf0FOIA==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} @@ -855,9 +846,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base-x@4.0.1: - resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} - base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} @@ -878,15 +866,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bs58@5.0.0: - resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} - bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} - bs58check@3.0.1: - resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} - bs58check@4.0.0: resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} @@ -936,6 +918,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1359,6 +1344,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1427,11 +1415,6 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1568,6 +1551,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -1614,94 +1600,47 @@ packages: snapshots: - '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.21(supports-color@7.2.0) - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.21(supports-color@7.2.0) - '@automerge/automerge-subduction': 0.13.0 - '@keyhive/keyhive': 0.0.0-alpha.57g - cbor-x: 1.6.4 - eventemitter3: 5.0.4 - isomorphic-ws: 5.0.0(ws@8.21.0) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - ws - - '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.29(supports-color@7.2.0)': + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.29(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.29(supports-color@7.2.0)': + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.29(supports-color@7.2.0) - debug: 4.4.3(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.21(supports-color@7.2.0)': + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.21(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) cbor-x: 1.6.4 debug: 4.4.3(supports-color@7.2.0) eventemitter3: 5.0.4 - isomorphic-ws: 5.0.0(ws@8.21.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.29(supports-color@7.2.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.29(supports-color@7.2.0) - cbor-x: 1.6.4 - debug: 4.4.3(supports-color@7.2.0) - eventemitter3: 5.0.4 - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.29(supports-color@7.2.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.29(supports-color@7.2.0) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@automerge/automerge-repo@2.6.0-subduction.21(supports-color@7.2.0)': + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: - '@automerge/automerge': 3.2.6 - '@automerge/automerge-subduction': 0.13.0 - bs58check: 3.0.1 - cbor-x: 1.6.4 - debug: 4.4.3(supports-color@7.2.0) - eventemitter3: 5.0.4 - fast-sha256: 1.3.0 - isomorphic-ws: 5.0.0(ws@8.21.0) - uuid: 9.0.1 - ws: 8.21.0 - xstate: 5.32.1 + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.29(supports-color@7.2.0)': + '@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)': dependencies: - '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-subduction': 0.15.0 + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 bs58check: 4.0.0 cbor-x: 1.6.4 debug: 4.4.3(supports-color@7.2.0) @@ -1716,7 +1655,7 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)': + '@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.2 '@automerge/automerge-subduction': 0.16.1 @@ -1734,30 +1673,12 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-subduction@0.13.0': {} - - '@automerge/automerge-subduction@0.15.0': {} - '@automerge/automerge-subduction@0.16.1': {} - '@automerge/automerge@3.2.6': {} - '@automerge/automerge@3.3.0-fragments.1': {} '@automerge/automerge@3.3.2': {} - '@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29(supports-color@7.2.0) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1887,6 +1808,33 @@ snapshots: '@cbor-extract/cbor-extract-win32-x64@2.2.2': optional: true + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.4 + + '@codemirror/state@6.7.5': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/view@6.43.12': + dependencies: + '@codemirror/state': 6.7.5 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -2027,74 +1975,64 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0)': + '@inkandswitch/patchwork-bootloader@0.7.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': dependencies: - '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.0) - '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29(supports-color@7.2.0) - '@automerge/automerge-subduction': 0.15.0 - '@automerge/vanillajs': 2.6.0-subduction.29(supports-color@7.2.0) - '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) - '@keyhive/keyhive': 0.0.0-alpha.56 + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48(supports-color@7.2.0) + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48(supports-color@7.2.0) + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48(supports-color@7.2.0) + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48(supports-color@7.2.0) + '@automerge/automerge-subduction': 0.16.1 + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@inkandswitch/patchwork-elements': 6.0.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-plugins': 1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) + '@keyhive/keyhive': 0.1.0-alpha.8 '@types/debug': 4.1.13 debug: 4.4.3(supports-color@7.2.0) resolve.exports: 2.0.3 service-worker-types: '@types/serviceworker@0.0.153' + solid-js: 1.9.14 tinyargs: 0.1.4 + optionalDependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) transitivePeerDependencies: - '@types/react' - bufferutil - react - - solid-js - supports-color - utf-8-validate - '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0)': + '@inkandswitch/patchwork-elements@6.0.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.5.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-plugins': 1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) debug: 4.4.3(supports-color@7.2.0) optionalDependencies: solid-js: 1.9.14 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)))(solid-js@1.9.14)(supports-color@7.2.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) - debug: 4.4.3(supports-color@7.2.0) - optionalDependencies: - solid-js: 1.9.14 - transitivePeerDependencies: - - supports-color - - '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': + '@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - '@types/debug': 4.1.13 - '@types/node': 20.19.43 debug: 4.4.3(supports-color@7.2.0) resolve.exports: 2.0.3 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0)': + '@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@7.2.0) @@ -2103,18 +2041,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-providers-solid@0.2.7(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0))(solid-js@1.9.14)': + '@inkandswitch/patchwork-providers-solid@0.2.8(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0))(solid-js@1.9.14)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.5.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) + '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0)) solid-automerge: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))(solid-js@1.9.14)(supports-color@7.2.0) solid-js: 1.9.14 - '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) - - '@inkandswitch/patchwork-providers@0.5.1(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))': + '@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.47(supports-color@7.2.0))': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.47(supports-color@7.2.0) @@ -2137,9 +2071,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@keyhive/keyhive@0.0.0-alpha.56': {} + '@keyhive/keyhive@0.1.0-alpha.8': {} - '@keyhive/keyhive@0.0.0-alpha.57g': {} + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.4': {} '@noble/hashes@1.8.0': {} @@ -2440,8 +2384,6 @@ snapshots: balanced-match@4.0.4: {} - base-x@4.0.1: {} - base-x@5.0.1: {} baseline-browser-mapping@2.10.37: {} @@ -2463,19 +2405,10 @@ snapshots: node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bs58@5.0.0: - dependencies: - base-x: 4.0.1 - bs58@6.0.0: dependencies: base-x: 5.0.1 - bs58check@3.0.1: - dependencies: - '@noble/hashes': 1.8.0 - bs58: 5.0.0 - bs58check@4.0.0: dependencies: '@noble/hashes': 1.8.0 @@ -2534,6 +2467,8 @@ snapshots: convert-source-map@2.0.0: {} + crelt@1.0.7: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2952,6 +2887,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-mod@4.1.4: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3008,8 +2945,6 @@ snapshots: uuid@14.0.0: {} - uuid@9.0.1: {} - vite-node@3.2.4(@types/node@20.19.43)(supports-color@7.2.0): dependencies: cac: 6.7.14 @@ -3119,6 +3054,8 @@ snapshots: - tsx - yaml + w3c-keyname@2.2.8: {} + webidl-conversions@7.0.0: {} whatwg-mimetype@3.0.0: {} diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 3a2e98e9..81210438 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -165,7 +165,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const selectDraft = (url: AutomergeUrl | null) => { const handle = checkedOutHandle(); - if (!handle) return; + if (!handle || disposed) return; setScrubber(null); setBaseliner(null); handle.change((d) => { @@ -181,6 +181,13 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // Monotonic counter so a slow checkpoint computation can't overwrite a newer // scrub position (a drag fires one recompute per snapped change). let scrubSeq = 0; + // The checkout doc outlives this sidebar (the provider reuses it across + // mounts), so work still in flight at unmount must not write into it. + let disposed = false; + onCleanup(() => { + disposed = true; + scrubSeq++; + }); // Recompute and persist the checkpoint from the current head (`scrubber`) // and baseline (`baseliner`) signals: `to`s follow the head, `from`s follow diff --git a/drafts/src/actor-attribution.ts b/drafts/src/actor-attribution.ts index 079098cb..692ffbcd 100644 --- a/drafts/src/actor-attribution.ts +++ b/drafts/src/actor-attribution.ts @@ -2,22 +2,32 @@ import { isValidAutomergeUrl, type AutomergeUrl, type DocHandle, + type DocHandleChangePayload, type Repo, } from "@automerge/automerge-repo/slim"; import * as Automerge from "@automerge/automerge/slim"; import { subscribe } from "@inkandswitch/patchwork-providers"; import type { ActorAttributionDoc, DraftDoc } from "./draft-types.js"; +import { editCountsSince } from "./change-group-cache.js"; -// Only the writing client knows which Automerge actor ids are its own. Each -// local change reveals one id, which the ActorRecorder attributes to the -// current user's contact. Changes seen before dependencies resolve are -// buffered. -export function createActorRecorder(element: HTMLElement): ActorRecorder { +// Only the writing client knows which Automerge actor ids are its own. The +// recorder watches the docs the user can currently write to (`watch`); each +// local change reveals one id, which it attributes to the current user's +// contact. The attribution doc is resolved through `resolveAttribution` only +// once there is an id to record, so viewing a document creates nothing. +// Changes seen before dependencies resolve are buffered. +export function createActorRecorder( + element: HTMLElement, + repo: Repo, + resolveAttribution: () => Promise | null> +): ActorRecorder { let contactUrl: AutomergeUrl | null = null; let attributionHandle: DocHandle | null = null; + let attribution: Promise | null = null; const recordedActorIds = new Set(); const pendingActorIds = new Set(); + const watched = new Map | null>(); let disposed = false; const unsubscribe = subscribe( @@ -32,33 +42,84 @@ export function createActorRecorder(element: HTMLElement): ActorRecorder { } ); + // `patchInfo.source` is "change" for remote updates too in this + // automerge-repo build, so a local write is recognised by its actor: one of + // the new changes was made by this doc instance's own actor id. Changes that + // only touch `@patchwork` (tools stamping metadata on open) don't count. + const onChange = (payload: DocHandleChangePayload) => { + if (disposed) return; + const { before, after } = payload.patchInfo; + const actorId = Automerge.getActorId(after); + if (recordedActorIds.has(actorId)) return; + const since = Automerge.getHeads(before); + const ours = Automerge.getChangesMetaSince(after, since).filter( + (meta) => meta.actor === actorId + ); + if (ours.length === 0) return; + void editCountsSince(after, since).then( + (counts) => { + if (disposed || !counts) return; + const edited = ours.some((meta) => { + const c = counts.get(meta.hash); + return !!c && c.additions + c.deletions > 0; + }); + if (!edited) return; + pendingActorIds.add(actorId); + flushPendingActors(); + }, + () => {} + ); + }; + return { - recordLocalChange(doc) { + watch(urls) { if (disposed) return; - let actorId: string; - try { - actorId = Automerge.getActorId(doc); - } catch { - return; + const wanted = new Set(urls); + for (const [url, handle] of [...watched]) { + if (wanted.has(url)) continue; + handle?.off("change", onChange); + watched.delete(url); + } + for (const url of wanted) { + if (watched.has(url)) continue; + watched.set(url, null); + repo.find(url).then( + (handle) => { + if (disposed || watched.get(url) !== null) return; + watched.set(url, handle); + handle.on("change", onChange); + }, + () => { + if (watched.get(url) === null) watched.delete(url); + } + ); } - if (recordedActorIds.has(actorId)) return; - pendingActorIds.add(actorId); - flushPendingActors(); - }, - setAttributionHandle(handle) { - if (disposed) return; - attributionHandle = handle; - flushPendingActors(); }, dispose() { disposed = true; pendingActorIds.clear(); + for (const [, handle] of watched) handle?.off("change", onChange); + watched.clear(); unsubscribe(); }, }; function flushPendingActors(): void { - if (!attributionHandle || !contactUrl || pendingActorIds.size === 0) return; + if (disposed || !contactUrl || pendingActorIds.size === 0) return; + if (!attributionHandle) { + attribution ??= resolveAttribution().then( + (handle) => { + if (disposed) return; + attributionHandle = handle; + if (handle) flushPendingActors(); + }, + (err) => { + attribution = null; + console.warn("[drafts] failed to resolve actor attribution:", err); + } + ); + return; + } const url = contactUrl; const actorIds = [...pendingActorIds]; pendingActorIds.clear(); @@ -103,8 +164,7 @@ export async function ensureActorAttribution( } export type ActorRecorder = { - recordLocalChange: (doc: Automerge.Doc) => void; - setAttributionHandle: (handle: DocHandle) => void; + watch: (urls: readonly AutomergeUrl[]) => void; dispose: () => void; }; diff --git a/drafts/src/change-group-cache.test.ts b/drafts/src/change-group-cache.test.ts new file mode 100644 index 00000000..07df47f2 --- /dev/null +++ b/drafts/src/change-group-cache.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import * as Automerge from "@automerge/automerge"; + +import { computeEditCounts, editCountsSince } from "./change-group-cache"; + +type Fixture = { + "@patchwork": { + type: string; + nested?: { a: number }; + copies?: string[]; + mainDraftUrl?: string; + }; + text: string; + list: { n: number }[]; + title?: string; +}; + +function history(): Automerge.Doc { + let doc = Automerge.from({ + "@patchwork": { type: "essay", nested: { a: 1 }, copies: [] }, + text: "", + list: [], + }); + for (let i = 0; i < 60; i++) { + doc = Automerge.change(doc, { time: 1_700_000_000 + i * 30 }, (d) => { + if (i % 7 === 0 && d.text.length > 5) { + Automerge.splice(d, ["text"], 0, 3); + } else { + Automerge.splice(d, ["text"], d.text.length, 0, `word${i} `); + } + if (i % 10 === 0) d.list.push({ n: i }); + if (i % 15 === 0) d["@patchwork"].nested!.a = i; + if (i === 20) d["@patchwork"].mainDraftUrl = "automerge:abc"; + if (i === 25) d["@patchwork"].copies!.push("automerge:copy"); + if (i === 30) d.title = "Hello"; + if (i === 40) delete d.title; + }); + } + let other = Automerge.clone(doc); + other = Automerge.change(other, { time: 1_700_009_000 }, (d) => { + Automerge.splice(d, ["text"], 0, 0, "Y"); + }); + doc = Automerge.change(doc, { time: 1_700_009_001 }, (d) => { + Automerge.splice(d, ["text"], 0, 0, "Z"); + }); + return Automerge.merge(doc, other); +} + +async function expectSameAsDiff( + doc: Automerge.Doc, + since: Automerge.Heads +) { + const counts = await editCountsSince(doc, since); + const metas = Automerge.getChangesMetaSince(doc, since); + expect(counts).not.toBeNull(); + expect(counts!.size).toBe(metas.length); + for (const meta of metas) { + expect(counts!.get(meta.hash), meta.hash).toEqual( + computeEditCounts(doc, meta.hash, meta.deps) + ); + } +} + +describe("editCountsSince", () => { + it("matches the per-change diff counts over the whole history", async () => { + await expectSameAsDiff(history(), []); + }); + + it("matches the diff counts on an incremental tail, including edits inside @patchwork containers created earlier", async () => { + const doc = history(); + const metas = Automerge.getChangesMetaSince(doc, []); + await expectSameAsDiff(doc, [metas[10].hash]); + await expectSameAsDiff(doc, [metas[24].hash]); + }); + + it("counts a metadata-only change as zero edits", async () => { + let doc = history(); + doc = Automerge.change(doc, (d) => { + d["@patchwork"].copies!.push("automerge:another"); + d["@patchwork"].nested!.a = 99; + d["@patchwork"].type = "note"; + }); + const heads = Automerge.getHeads(doc); + const counts = await editCountsSince(doc, [ + Automerge.getChangesMetaSince(doc, []).at(-2)!.hash, + ]); + expect(counts!.get(heads[0])).toEqual({ additions: 0, deletions: 0 }); + }); + + it("counts a mark once, like the diff does", async () => { + let doc = history(); + doc = Automerge.change(doc, (d) => { + Automerge.mark( + d, + ["text"], + { start: 0, end: 4, expand: "none" }, + "bold", + true + ); + }); + const [head] = Automerge.getHeads(doc); + const meta = Automerge.getChangesMetaSince(doc, []).find( + (m) => m.hash === head + )!; + const counts = await editCountsSince(doc, meta.deps); + expect(counts!.get(head)).toEqual(computeEditCounts(doc, head, meta.deps)); + }); + + it("resolves null when the tick aborts", async () => { + expect(await editCountsSince(history(), [], async () => false)).toBeNull(); + }); +}); diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts index d71187db..afd7207e 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -4,7 +4,6 @@ import { isValidAutomergeUrl, type AutomergeUrl, type DocHandle, - type DocHandleChangePayload, type Repo, type UrlHeads, } from "@automerge/automerge-repo/slim"; @@ -18,6 +17,9 @@ import type { } from "./draft-types.js"; // Bump to discard every existing group doc's contents (they self-rebuild). +// Not bumped for the switch from per-change diffs to op counting: the numbers +// agree, and a bump would make still-deployed clients and this one wipe each +// other's group docs in turn. export const CHANGE_GROUP_DOC_VERSION = 1; // A pause between consecutive changes longer than this starts a new group: @@ -32,10 +34,6 @@ const SLICE_BUDGET_MS = 8; // Coalesce bursts of member-doc change events into one grouping update. const GROUPING_DEBOUNCE_MS = 250; -// Change-event sources that mean a LOCAL edit (made through this client's -// doc instance), as opposed to synced/merged remote changes. -const LOCAL_PATCH_SOURCES = new Set(["change", "changeAt", "emptyChange"]); - // One timeline the ChangeGrouper is responsible for: the DraftDoc that owns it // (main included — the main draft is always real), the member docs whose // interleaved changes make it up, and the host doc whose creation time is @@ -54,13 +52,6 @@ export type ChangeGrouper = { dispose: () => void; }; -export type ChangeGrouperOptions = { - // Called when a watched member doc receives a local change — i.e. the - // current user just wrote with that doc instance's actor id. Feeds the - // ActorRecorder (see actor-attribution.ts). - onLocalChange?: (doc: Automerge.Doc) => void; -}; - // Resolve a draft's change-group doc, creating it and stamping // `changeGroupDocUrl` the first time. A document whose format version or // grouping parameter no longer matches is emptied in place and rebuilt. @@ -171,6 +162,71 @@ export function computeRangeEditCounts( } } +// The same +/- magnitude for every change since `since`, straight from each +// change's own ops and keyed by hash: every insert / set / make / mark counts +// as one addition, every del as one deletion (a text splice is one op per +// character, so the totals match the patch-based count). Ops under +// `@patchwork` are ignored: the skip set starts from the objects currently in +// that subtree and, since changes come back in causal order, picks up objects +// created inside it along the way. +export async function editCountsSince( + doc: Automerge.Doc, + since: Automerge.Heads, + tick: () => Promise = async () => true +): Promise | null> { + const skipObjs = new Set(); + objectIdsUnder((doc as Record)["@patchwork"], skipObjs); + const counts = new Map(); + for (const change of Automerge.getChangesSince(doc, since)) { + if (!(await tick())) return null; + const decoded = Automerge.decodeChange(change); + counts.set(decoded.hash, countOps(decoded, skipObjs)); + } + return counts; +} + +// Object ids of a materialized container and everything inside it, including +// the text objects behind string values in maps (strings inside lists carry +// no reachable id). +function objectIdsUnder(value: unknown, out: Set): void { + if (!value || typeof value !== "object") return; + const id = Automerge.getObjectId(value); + if (id) out.add(id); + if (Array.isArray(value)) { + for (const item of value) objectIdsUnder(item, out); + return; + } + for (const [key, child] of Object.entries(value)) { + if (typeof child === "string") { + const textId = Automerge.getObjectId(value, key); + if (typeof textId === "string") out.add(textId); + } else { + objectIdsUnder(child, out); + } + } +} + +function countOps( + change: Automerge.DecodedChange, + skipObjs: Set +): EditCounts { + let additions = 0; + let deletions = 0; + change.ops.forEach((op, i) => { + const skipped = + skipObjs.has(op.obj) || (op.obj === "_root" && op.key === "@patchwork"); + if (op.action.startsWith("make") && skipped) { + skipObjs.add(`${change.startOp + i}@${change.actor}`); + } + if (skipped || op.action === "markEnd") return; + if (op.action === "del") deletions += 1; + else additions += 1; + }); + return { additions, deletions }; +} + +export type EditCounts = { additions: number; deletions: number }; + // Shared patch-counting rules for the two diff flavors above. function countPatches(patches: Automerge.Patch[]): { additions: number; @@ -199,32 +255,37 @@ function countPatches(patches: Automerge.Patch[]): { // full history and returns its first change's time (the creation change). // Returns undefined when the doc, its history, or that time can't be resolved, // in which case callers skip the "before creation" filter rather than hiding -// everything. +// everything. Immutable once known, so resolved times are cached per url. export async function getDocCreationTime( repo: Repo, url: AutomergeUrl | undefined ): Promise { if (!url) return undefined; + const cached = creationTimes.get(url); + if (cached !== undefined) return cached; try { const handle = await repo.find(url); const doc = handle.doc(); if (!doc) return undefined; const metas = Automerge.getChangesMetaSince(doc, []); - return metas[0]?.time || undefined; + const time = metas[0]?.time || undefined; + if (time !== undefined) creationTimes.set(url, time); + return time; } catch (err) { console.warn("[drafts] failed to resolve creation time for:", url, err); return undefined; } } -// One member change awaiting aggregation. `seq` is the change's index within -// its member's gathered metas, used only to break same-second timestamp ties -// (meta.time is second-resolution) with the doc's own causal order. -type PendingChange = { +const creationTimes = new Map(); + +// One member change awaiting aggregation, with its edit counts already +// resolved. `seq` is the change's index within its member's gathered metas, +// used only to break same-second timestamp ties (meta.time is +// second-resolution) with the doc's own causal order. +type PendingChange = EditCounts & { memberUrl: AutomergeUrl; - doc: Automerge.Doc; hash: string; - deps: string[]; time: number; actor: string; seq: number; @@ -272,37 +333,42 @@ function groupId(rowsNewestFirst: PendingChange[]): string { // before the root document was created (a member dragged in after the fact // would otherwise contribute pre-existing history that reads as noise). `seq` // still reflects each change's position in the gathered metas, so filtering -// doesn't disturb the tie-break ordering. -function collectMemberRows( +// doesn't disturb the tie-break ordering. Each change is decoded once for its +// edit counts, in idle slices; resolves false if the run was aborted. +async function collectMemberRows( out: PendingChange[], member: DraftMemberDoc, doc: Automerge.Doc, since: Automerge.Heads, - createdAt: number | undefined -): void { - let metas; + createdAt: number | undefined, + slicer: Slicer +): Promise { + let metas: Automerge.ChangeMetadata[]; + let counts: Map | null; try { metas = Automerge.getChangesMetaSince(doc, since); + counts = await editCountsSince(doc, since, slicer.tick); } catch (err) { console.warn( "[drafts] change grouping: failed to read changes for member:", member.url, err ); - return; + return true; } + if (!counts) return false; metas.forEach((meta, seq) => { if (createdAt !== undefined && meta.time && meta.time < createdAt) return; out.push({ memberUrl: member.url, - doc, hash: meta.hash, - deps: meta.deps, time: meta.time, actor: meta.actor, seq, + ...(counts!.get(meta.hash) ?? { additions: 0, deletions: 0 }), }); }); + return true; } function dedupedActors(rowsNewestFirst: PendingChange[]): string[] { @@ -317,7 +383,7 @@ function dedupedActors(rowsNewestFirst: PendingChange[]): string[] { function idle(): Promise { return new Promise((resolve) => { if (typeof requestIdleCallback === "function") { - requestIdleCallback(() => resolve()); + requestIdleCallback(() => resolve(), { timeout: 200 }); } else { setTimeout(resolve, 0); } @@ -325,17 +391,16 @@ function idle(): Promise { } // Time-budgeted cooperative slicing: `tick()` resolves immediately while the -// current slice has budget left, otherwise runs `onYield` (flush pending -// writes) and waits for idle time. Resolves false once the run is aborted. +// current slice has budget left, otherwise waits for idle time. Resolves +// false once the run is aborted. type Slicer = { tick: () => Promise }; -function createSlicer(isAborted: () => boolean, onYield: () => void): Slicer { +function createSlicer(isAborted: () => boolean): Slicer { let sliceStart = performance.now(); return { async tick(): Promise { if (isAborted()) return false; if (performance.now() - sliceStart < SLICE_BUDGET_MS) return true; - onYield(); await idle(); sliceStart = performance.now(); return !isAborted(); @@ -343,21 +408,19 @@ function createSlicer(isAborted: () => boolean, onYield: () => void): Slicer { }; } -// Diff every change in a group (newest-first, sliced) and aggregate the -// result down to the ChangeGroup a timeline row renders. Returns null when -// the run was aborted mid-diff. -async function buildGroup( - rowsNewestFirst: PendingChange[], - slicer: Slicer -): Promise { +function sumCounts(rows: PendingChange[]): EditCounts { let additions = 0; let deletions = 0; - for (const row of rowsNewestFirst) { - if (!(await slicer.tick())) return null; - const counts = computeEditCounts(row.doc, row.hash, row.deps); - additions += counts.additions; - deletions += counts.deletions; + for (const row of rows) { + additions += row.additions; + deletions += row.deletions; } + return { additions, deletions }; +} + +// Aggregate a group's changes (newest-first) down to the ChangeGroup a +// timeline row renders. +function buildGroup(rowsNewestFirst: PendingChange[]): ChangeGroup { const newest = rowsNewestFirst[0]; const oldest = rowsNewestFirst[rowsNewestFirst.length - 1]; return { @@ -367,8 +430,7 @@ async function buildGroup( newestMemberUrl: newest.memberUrl, newestHash: newest.hash, actors: dedupedActors(rowsNewestFirst), - additions, - deletions, + ...sumCounts(rowsNewestFirst), changeCount: rowsNewestFirst.length, }; } @@ -391,16 +453,13 @@ export function sameHeads( // background task per timeline, listens to member docs for edits, and updates // newest groups first while older history backfills. One global runner // processes timelines sequentially in the priority order it was given. -export function createChangeGrouper( - repo: Repo, - options: ChangeGrouperOptions = {} -): ChangeGrouper { +export function createChangeGrouper(repo: Repo): ChangeGrouper { // Change listeners on the docs a timeline reads (originals for main, // clones for drafts). `handle` is null while the doc is still resolving — // the slot is reserved up front so concurrent syncs don't double-attach. type SourceListener = { handle: DocHandle | null; - onChange: (payload: DocHandleChangePayload) => void; + onChange: () => void; }; type Task = { @@ -416,17 +475,6 @@ export function createChangeGrouper( let running = false; let disposed = false; - // Host-doc creation times, resolved once per root url. - const creationTimes = new Map>(); - const creationTime = (url: AutomergeUrl): Promise => { - let cached = creationTimes.get(url); - if (!cached) { - cached = getDocCreationTime(repo, url); - creationTimes.set(url, cached); - } - return cached; - }; - function setTimelines(specs: TimelineGroupingSpec[]): void { if (disposed) return; const keep = new Set(specs.map((s) => s.draftHandle.url)); @@ -489,14 +537,7 @@ export function createChangeGrouper( } for (const url of wanted) { if (task.listeners.has(url)) continue; - const onChange = (payload: DocHandleChangePayload) => { - if ( - options.onLocalChange && - LOCAL_PATCH_SOURCES.has(payload.patchInfo.source) && - payload.doc - ) { - options.onLocalChange(payload.doc); - } + const onChange = () => { if (task.debounce) clearTimeout(task.debounce); task.debounce = setTimeout(() => { task.debounce = null; @@ -571,7 +612,7 @@ export function createChangeGrouper( repo, spec.draftHandle ); - const createdAt = await creationTime(spec.rootDocUrl); + const createdAt = await getDocCreationTime(repo, spec.rootDocUrl); if (isAborted()) return; // Resolve member sources, sorted by member url so cross-doc timestamp @@ -601,6 +642,7 @@ export function createChangeGrouper( // Each member's frontier as of this gather; the consumed marker advances // to exactly these once the run completes, so the next run's // getChangesMetaSince yields precisely the unconsumed tail. + const slicer = createSlicer(isAborted); const frontier: Record = {}; const tails: PendingChange[] = []; for (const { member, doc } of sources) { @@ -611,8 +653,11 @@ export function createChangeGrouper( : member.clonedAt ? decodeHeads(member.clonedAt) : []; - collectMemberRows(tails, member, doc, since, createdAt); + if (!(await collectMemberRows(tails, member, doc, since, createdAt, slicer))) { + return; + } } + if (isAborted()) return; if (tails.length === 0) { // Nothing new to group; just record any frontier movement (e.g. members @@ -651,34 +696,28 @@ export function createChangeGrouper( tailOldestMs - secondStored.endTime * 1000 > INACTIVITY_GAP_MS); if (fastOk) { - await appendTail( - changeGroupHandle, - newestStored, - tails, - frontier, - isAborted - ); + appendTail(changeGroupHandle, newestStored, tails, frontier); } else { await rebuildAll( changeGroupHandle, sources, createdAt, frontier, - isAborted + isAborted, + slicer ); } } - // Incremental append: diff only the tail, then extend the newest stored - // group (accumulate counts, union actors, bump the anchor) and/or open new - // groups above it. No stored aggregate is ever decomposed. - async function appendTail( + // Incremental append: extend the newest stored group with the tail + // (accumulate counts, union actors, bump the anchor) and/or open new groups + // above it. No stored aggregate is ever decomposed. + function appendTail( changeGroupHandle: DocHandle, newestStored: ChangeGroup, tailsNewestFirst: PendingChange[], - frontier: Record, - isAborted: () => boolean - ): Promise { + frontier: Record + ): void { const tailGroups = splitIntoGroups(tailsNewestFirst); const oldestGroup = tailGroups[tailGroups.length - 1]; const oldestGroupOldestMs = @@ -689,39 +728,31 @@ export function createChangeGrouper( oldestGroupOldestMs <= newestStored.endTime * 1000 + INACTIVITY_GAP_MS; const freshGroups = attaches ? tailGroups.slice(0, -1) : tailGroups; - const slicer = createSlicer(isAborted, () => {}); - const built: ChangeGroup[] = []; - for (const rows of freshGroups) { - const group = await buildGroup(rows, slicer); - if (group === null) return; - built.push(group); - } - - let extensionSums: { additions: number; deletions: number } | null = null; - if (attaches) { - let additions = 0; - let deletions = 0; - for (const row of oldestGroup) { - if (!(await slicer.tick())) return; - const counts = computeEditCounts(row.doc, row.hash, row.deps); - additions += counts.additions; - deletions += counts.deletions; - } - extensionSums = { additions, deletions }; - } - - if (isAborted()) return; changeGroupHandle.change((d) => { - for (const group of built) d.groups[group.id] = group; - if (attaches && extensionSums) { - extendGroup(d, newestStored, oldestGroup, extensionSums); + for (const rows of freshGroups) { + const group = buildGroup(rows); + d.groups[group.id] = group; } - for (const [url, heads] of Object.entries(frontier)) { - d.computedThrough[url as AutomergeUrl] = heads; + if (attaches) { + extendGroup(d, newestStored, oldestGroup, sumCounts(oldestGroup)); } + advanceConsumed(d, frontier); }); } + // Move each member's consumed marker to its gathered frontier. Assigning an + // array is never a no-op in Automerge, so unchanged markers are left alone. + function advanceConsumed( + d: ChangeGroupDoc, + frontier: Record + ): void { + for (const [url, heads] of Object.entries(frontier)) { + if (!sameHeads(d.computedThrough[url as AutomergeUrl], heads)) { + d.computedThrough[url as AutomergeUrl] = heads; + } + } + } + // Fold a tail run into the newest stored group inside an open change(): // re-read the group from the live doc so a concurrently synced extension is // extended further rather than clobbered. @@ -777,64 +808,50 @@ export function createChangeGrouper( d.groups[extended.id] = extended; } - // Full rebuild: regather every member's post-fork history, re-split, and - // diff newest-first in idle slices — flushing completed groups as each - // slice ends so recent history paints while older history backfills. A - // stored group whose id, span, and change count match is reused without - // re-diffing (cheap warm restarts, and no redundant work when another - // client's grouping update syncs in). Stale ids and consumed markers settle - // in the final write. + // Full rebuild: regather every member's post-fork history (decoded in idle + // slices), re-split, and write the groups in one change. A stored group + // whose id, span, and change count match is left untouched (no write when + // another client's grouping update syncs in). Stale ids and consumed + // markers settle in the same write. async function rebuildAll( changeGroupHandle: DocHandle, sources: { member: DraftMemberDoc; doc: Automerge.Doc }[], createdAt: number | undefined, frontier: Record, - isAborted: () => boolean + isAborted: () => boolean, + slicer: Slicer ): Promise { const rows: PendingChange[] = []; for (const { member, doc } of sources) { const since = member.clonedAt ? decodeHeads(member.clonedAt) : []; - collectMemberRows(rows, member, doc, since, createdAt); + if (!(await collectMemberRows(rows, member, doc, since, createdAt, slicer))) { + return; + } } + if (isAborted()) return; rows.sort(newestFirst); const groupsRows = splitIntoGroups(rows); const expectedIds = new Set(groupsRows.map(groupId)); - const batch: ChangeGroup[] = []; - const flush = () => { - if (batch.length === 0) return; - changeGroupHandle.change((d) => { - for (const group of batch) d.groups[group.id] = group; - }); - batch.length = 0; - }; - - const slicer = createSlicer(isAborted, flush); - for (const groupRows of groupsRows) { - const id = groupId(groupRows); - const existing = changeGroupHandle.doc()?.groups?.[id]; - if ( - existing && - existing.changeCount === groupRows.length && - existing.startTime === groupRows[groupRows.length - 1].time && - existing.endTime === groupRows[0].time - ) { - continue; - } - const group = await buildGroup(groupRows, slicer); - if (group === null) return; // aborted mid-diff; markers stay put - batch.push(group); - } + const stored = changeGroupHandle.doc()?.groups ?? {}; + const fresh = groupsRows + .filter((groupRows) => { + const existing = stored[groupId(groupRows)]; + return !( + existing && + existing.changeCount === groupRows.length && + existing.startTime === groupRows[groupRows.length - 1].time && + existing.endTime === groupRows[0].time + ); + }) + .map(buildGroup); - if (isAborted()) return; changeGroupHandle.change((d) => { - for (const group of batch) d.groups[group.id] = group; + for (const group of fresh) d.groups[group.id] = group; for (const id of Object.keys(d.groups)) { if (!expectedIds.has(id)) delete d.groups[id]; } - for (const [url, heads] of Object.entries(frontier)) { - d.computedThrough[url as AutomergeUrl] = heads; - } + advanceConsumed(d, frontier); }); } } diff --git a/drafts/src/providers/DraftStateProvider.ts b/drafts/src/providers/DraftStateProvider.ts index c8e4e178..1e6150ee 100644 --- a/drafts/src/providers/DraftStateProvider.ts +++ b/drafts/src/providers/DraftStateProvider.ts @@ -13,6 +13,7 @@ import type { } from "@inkandswitch/patchwork-elements"; import type { + ActorAttributionDoc, Baseline, CheckedOutDraft, CloneEntry, @@ -31,7 +32,7 @@ import { createActorRecorder, ensureActorAttribution, } from "../actor-attribution.js"; -import { createDraftRouter, type DraftRouter } from "../draft-routing.js"; +import { createDraftRouter } from "../draft-routing.js"; import { ensureMainDraft } from "../draft-docs.js"; const ROOT_DOC_SELECTOR = "draft:root-doc"; @@ -45,6 +46,32 @@ const ATTR_DOC_URL = "doc-url"; // "from the start", so `getChangesMetaSince(doc, [])` yields the full history. const EMPTY_HEADS: UrlHeads = encodeHeads([]); +// One ephemeral CheckedOutDraft per host doc for the life of this realm. The +// host remounts this provider on every doc switch; a fresh doc per mount +// would be stored and synced to every peer each time (delete is local-only), +// so the doc is kept and reset to main on remount instead. +const checkoutDocs = new Map>(); + +function checkoutFor( + repo: Repo, + docUrl: AutomergeUrl +): DocHandle { + let handle = checkoutDocs.get(docUrl); + if (!handle) { + handle = repo.create({ checkedOut: null }); + checkoutDocs.set(docUrl, handle); + return handle; + } + const doc = handle.doc(); + if (doc?.checkedOut != null || doc?.at != null) { + handle.change((d) => { + d.checkedOut = null; + d.at = null; + }); + } + return handle; +} + // Mounts on a document URL and exposes that document's draft state via three // subscriptions: // - `draft:root-doc` → AutomergeUrl of the doc this provider is on @@ -61,6 +88,12 @@ const EMPTY_HEADS: UrlHeads = encodeHeads([]); // to its drafts is `@patchwork.mainDraftUrl` → the main draft, whose `drafts` // roots the tree; each `DraftDoc` may have its own sub-drafts via // `DraftDoc.drafts`. +// +// Viewing a document costs nothing persistent. The host doc's draft +// bookkeeping (its main draft and ActorAttributionDoc) is created on demand: +// when the sidebar first subscribes to `draft:list`, or when the user first +// edits a doc beneath us. Timelines are grouped only while a `draft:list` +// subscriber exists. export const DraftStateProvider = (element: HTMLElement) => { const rawUrl = element.getAttribute(ATTR_DOC_URL); if (!rawUrl || !isValidAutomergeUrl(rawUrl)) { @@ -82,17 +115,14 @@ export const DraftStateProvider = (element: HTMLElement) => { const repo: Repo = maybeRepo; let hostDocHandle: DocHandle | null = null; - let checkedOutHandle: DocHandle | null = null; - let draftRouter: DraftRouter | null = null; const trackedDrafts = new Map>(); // The host doc's single main draft (bookkeeping only). Resolved lazily from // `@patchwork.mainDraftUrl`; its `drafts` roots the draft tree and its // identity `clones` back the "main" member list. let mainDraftHandle: DocHandle | null = null; - // `draft:checked-out` subscribers that arrived before the ephemeral - // CheckedOutDraft doc was created; flushed once it exists. - const pendingCheckedOutSubscribers = new Set<(url: AutomergeUrl) => void>(); + const checkedOutHandle = checkoutFor(repo, docUrl); + const draftRouter = createDraftRouter(checkedOutHandle); // `draft:baseline` subscribers, keyed by canonical target url. This provider // is the sole answerer (the overlay no longer claims it), serving the @@ -102,6 +132,7 @@ export const DraftStateProvider = (element: HTMLElement) => { AutomergeUrl, Set<(baseline: Baseline) => void> >(); + const sentBaselines = new Map(); // `draft:list` bookkeeping: the last computed list, its live subscribers, and // the draft order from the most recent rewalk. @@ -121,12 +152,9 @@ export const DraftStateProvider = (element: HTMLElement) => { }; // Author attribution: stamps actor ids from local member-doc changes into // the host doc's shared ActorAttributionDoc. - const actorRecorder = createActorRecorder(element); - // Keep every timeline's ChangeGroupDoc current whether or not the sidebar - // is open. Member-doc listeners drive updates between list recomputes. - const changeGrouper = createChangeGrouper(repo, { - onLocalChange: actorRecorder.recordLocalChange, - }); + const actorRecorder = createActorRecorder(element, repo, ensureAttribution); + // Groups each timeline into its ChangeGroupDoc while the sidebar is open. + const changeGrouper = createChangeGrouper(repo); // Main-case membership: docs mounted beneath this provider, ref-counted so a // doc shown in several views is only dropped on its last unmount. Populated // even while a draft is selected (where it goes unused) so switching back to @@ -140,16 +168,26 @@ export const DraftStateProvider = (element: HTMLElement) => { let disposed = false; let rewalkInFlight = false; let rewalkPending = false; + let cloneSyncTimer: ReturnType | null = null; + let mainDraftReady: Promise | null> | null = null; + let attributionReady: Promise | null> | null = + null; // A tracked draft changing can mean either its sub-draft list moved (needs a // rewalk) or its clone map grew (needs a list recompute), so do both. const onTrackedChange = () => { scheduleRewalk(); - recomputeList(); + reconcile(); + }; + const onHostDocChange = () => { + const url = hostDocHandle?.doc()?.["@patchwork"]?.mainDraftUrl ?? null; + if (url !== (mainDraftHandle?.url ?? null)) scheduleRewalk(); }; - const onHostDocChange = () => scheduleRewalk(); // The checkout doc changed: its checkpoint may have moved, so re-publish // every live `draft:baseline` subscriber. - const onCheckedOutChange = () => notifyBaselines(); + const onCheckedOutChange = () => { + notifyBaselines(); + reconcile(); + }; const onMounted = (event: MountedEvent) => { const detail = event.detail; @@ -157,8 +195,8 @@ export const DraftStateProvider = (element: HTMLElement) => { const url = canonicalUrl(detail.url); mountCounts.set(url, (mountCounts.get(url) ?? 0) + 1); ensureSkipVerdict(url); - syncMainDraftClones(); - recomputeList(); + scheduleCloneSync(); + reconcile(); }; const onUnmounted = (event: UnmountedEvent) => { @@ -168,57 +206,23 @@ export const DraftStateProvider = (element: HTMLElement) => { const count = mountCounts.get(url) ?? 0; if (count <= 1) mountCounts.delete(url); else mountCounts.set(url, count - 1); - recomputeList(); + reconcile(); }; const ready: Promise = (async () => { const handle = await repo.find(docUrl); if (disposed) return; hostDocHandle = handle; - - // Eagerly create the main draft (a real doc, not the virtual fallback) so - // its ChangeGroupDoc has a persistent home even if the sidebar is never - // opened. Main's clones are identity mappings — nothing is forked; - // the only host-doc side effect is the `mainDraftUrl` scalar, which the - // timeline's `@patchwork` path skip filters out. App-global datatypes the - // draft machinery never treats as content are left alone. - const hostType = handle.doc()?.["@patchwork"]?.type; - if (hostType == null || !SKIPPED_DATATYPES.has(hostType)) { - try { - const mainDraft = await ensureMainDraft(repo, handle); - if (disposed) return; - // The attribution doc is shared by every timeline on this host doc; - // once it resolves the ActorRecorder starts stamping local actor ids. - const attribution = await ensureActorAttribution(repo, mainDraft); - if (disposed) return; - actorRecorder.setAttributionHandle(attribution); - } catch (err) { - console.warn("[drafts] failed to eagerly create main draft:", err); - } - } - - // Eagerly create the ephemeral CheckedOutDraft so the sidebar can render - // its "Main" card and write `checkedOut` even before any drafts exist on - // the host doc. - checkedOutHandle = repo.create({ checkedOut: null }); - checkedOutHandle.on("change", onCheckedOutChange); - draftRouter = createDraftRouter(checkedOutHandle); - const checkedOutUrl = checkedOutHandle.url; - for (const respond of pendingCheckedOutSubscribers) { - respond(checkedOutUrl); - } - pendingCheckedOutSubscribers.clear(); - // A checkpoint may already have synced in before this provider mounted. - notifyBaselines(); - handle.on("change", onHostDocChange); scheduleRewalk(); - recomputeList(); + reconcile(); })(); ready.catch((err) => { console.error(`[drafts] failed to initialize draft-state provider:`, err); }); + checkedOutHandle.on("change", onCheckedOutChange); + const onSubscribe = (event: SubscribeEvent) => { const { type } = event.detail.selector; @@ -231,12 +235,7 @@ export const DraftStateProvider = (element: HTMLElement) => { if (type === CHECKED_OUT_SELECTOR) { accept(event, (respond) => { - if (checkedOutHandle) { - respond(checkedOutHandle.url); - return; - } - pendingCheckedOutSubscribers.add(respond); - return () => pendingCheckedOutSubscribers.delete(respond); + respond(checkedOutHandle.url); }); return; } @@ -248,7 +247,11 @@ export const DraftStateProvider = (element: HTMLElement) => { accept(event, (respond) => { respond(draftList); listSubscribers.add(respond); - return () => listSubscribers.delete(respond); + if (listSubscribers.size === 1) activate(); + return () => { + listSubscribers.delete(respond); + if (listSubscribers.size === 0) deactivate(); + }; }); return; } @@ -263,18 +266,22 @@ export const DraftStateProvider = (element: HTMLElement) => { } const target = canonicalUrl(rawTarget); accept(event, (respond) => { - respond(currentBaseline(target)); + const baseline = currentBaseline(target); + sentBaselines.set(target, baseline.heads); + respond(baseline); let set = baselineSubscribers.get(target); if (!set) baselineSubscribers.set(target, (set = new Set())); set.add(respond); return () => { set!.delete(respond); - if (set!.size === 0) baselineSubscribers.delete(target); + if (set!.size === 0) { + baselineSubscribers.delete(target); + sentBaselines.delete(target); + } }; }); return; } - }; element.addEventListener("patchwork:subscribe", onSubscribe); @@ -283,34 +290,86 @@ export const DraftStateProvider = (element: HTMLElement) => { return () => { disposed = true; + if (cloneSyncTimer !== null) clearTimeout(cloneSyncTimer); changeGrouper.dispose(); actorRecorder.dispose(); - draftRouter?.dispose(); - draftRouter = null; + draftRouter.dispose(); element.removeEventListener("patchwork:subscribe", onSubscribe); element.removeEventListener("patchwork:mounted", onMounted); element.removeEventListener("patchwork:unmounted", onUnmounted); - if (hostDocHandle) hostDocHandle.off("change", onHostDocChange); - if (mainDraftHandle) mainDraftHandle.off("change", onTrackedChange); + hostDocHandle?.off("change", onHostDocChange); + mainDraftHandle?.off("change", onTrackedChange); for (const [, h] of trackedDrafts) h.off("change", onTrackedChange); + checkedOutHandle.off("change", onCheckedOutChange); mainDraftHandle = null; trackedDrafts.clear(); - pendingCheckedOutSubscribers.clear(); listSubscribers.clear(); baselineSubscribers.clear(); + sentBaselines.clear(); mountCounts.clear(); skipVerdicts.clear(); - if (checkedOutHandle) { - checkedOutHandle.off("change", onCheckedOutChange); - repo.delete(checkedOutHandle.url); - } - checkedOutHandle = null; hostDocHandle = null; }; + // The sidebar is open: make sure the host doc has its main draft (so the + // Main timeline has a home), record what's mounted as main's members, and + // keep the timelines grouped. + function activate(): void { + ensureMainDraftDoc().then( + () => { + scheduleCloneSync(); + reconcile(); + }, + (err) => console.warn("[drafts] failed to create main draft:", err) + ); + reconcile(); + } + + function deactivate(): void { + changeGrouper.setTimelines([]); + } + + // Resolve (creating on first use) the host doc's main draft. Main's clones + // are identity mappings — nothing is forked; the only host-doc side effect + // is the `mainDraftUrl` scalar, which the timeline's `@patchwork` path skip + // filters out. App-global datatypes the draft machinery never treats as + // content are left alone (`null`). A failed attempt is retried next time. + function ensureMainDraftDoc(): Promise | null> { + if (mainDraftReady) return mainDraftReady; + const attempt = (async () => { + await ready; + if (disposed || !hostDocHandle) return null; + const hostType = hostDocHandle.doc()?.["@patchwork"]?.type; + if (hostType != null && SKIPPED_DATATYPES.has(hostType)) return null; + const mainDraft = await ensureMainDraft(repo, hostDocHandle); + if (disposed) return null; + scheduleRewalk(); + return mainDraft; + })(); + mainDraftReady = attempt; + attempt.catch(() => { + if (mainDraftReady === attempt) mainDraftReady = null; + }); + return attempt; + } + + // The host doc's ActorAttributionDoc, created (with the main draft it hangs + // off) on the first local edit — only writers need it. + function ensureAttribution(): Promise | null> { + if (attributionReady) return attributionReady; + const attempt = ensureMainDraftDoc().then((mainDraft) => + mainDraft && !disposed ? ensureActorAttribution(repo, mainDraft) : null + ); + attributionReady = attempt; + attempt.catch(() => { + if (attributionReady === attempt) attributionReady = null; + }); + return attempt; + } + function scheduleRewalk(): void { if (disposed) return; - if (!hostDocHandle || !checkedOutHandle) return; + if (!hostDocHandle) return; if (rewalkInFlight) { rewalkPending = true; return; @@ -329,18 +388,25 @@ export const DraftStateProvider = (element: HTMLElement) => { repo, roots, trackedDrafts, - onTrackedChange + onTrackedChange, + () => disposed ); if (disposed) return; + const reachable = new Set(allDrafts); + for (const [url, handle] of trackedDrafts) { + if (reachable.has(url)) continue; + handle.off("change", onTrackedChange); + trackedDrafts.delete(url); + } orderedDraftUrls = allDrafts; - draftRouter?.updateAvailableDrafts(allDrafts); + draftRouter.updateAvailableDrafts(allDrafts); } catch (err) { console.error("[drafts] rewalk failed:", err); } finally { rewalkInFlight = false; // A rewalk may have just started tracking a draft (whose clones won't // fire their own change event), so refresh the list. - recomputeList(); + reconcile(); if (rewalkPending) { rewalkPending = false; scheduleRewalk(); @@ -349,17 +415,36 @@ export const DraftStateProvider = (element: HTMLElement) => { })(); } - // Recompute the `draft:list` value and push it to subscribers when it - // actually changed. The ChangeGrouper follows the same triggers. - function recomputeList(): void { + // Bring everything derived from the current state up to date: which docs + // the actor recorder watches, which timelines are grouped (sidebar open + // only), and the `draft:list` value, pushed to subscribers when it actually + // changed. + function reconcile(): void { if (disposed) return; - updateChangeGrouping(); + actorRecorder.watch(writableDocUrls()); + if (listSubscribers.size > 0) updateChangeGrouping(); const next = computeList(); if (draftListsEqual(draftList, next)) return; draftList = next; for (const respond of listSubscribers) respond(next); } + // The docs the user can write to right now: the mounted originals on main, + // plus the checked-out draft's clones, where the overlay routes edits. + function writableDocUrls(): AutomergeUrl[] { + const urls = [...mountCounts.keys()].filter( + (url) => skipVerdicts.get(url) === false + ); + const selected = checkedOutHandle.doc()?.checkedOut ?? null; + const clones = selected + ? trackedDrafts.get(selected)?.doc()?.clones + : undefined; + for (const entry of Object.values(clones ?? {})) { + urls.push(canonicalUrl(entry.cloneUrl)); + } + return urls; + } + // Hand the ChangeGrouper the current timelines, priority-ordered: main, // checked-out draft, then the remaining drafts in tree order. Main reads // the main draft's identity clones rather than the mounted-doc fallback. @@ -373,7 +458,7 @@ export const DraftStateProvider = (element: HTMLElement) => { rootDocUrl: docUrl, }); } - const selected = checkedOutHandle?.doc()?.checkedOut ?? null; + const selected = checkedOutHandle.doc()?.checkedOut ?? null; const ordered = selected && orderedDraftUrls.includes(selected) ? [selected, ...orderedDraftUrls.filter((u) => u !== selected)] @@ -470,13 +555,19 @@ export const DraftStateProvider = (element: HTMLElement) => { // fork-point fallback: baselines exist only when explicitly written into // the checkpoint, so "diffs hidden" is simply their absence. function currentBaseline(target: AutomergeUrl): Baseline { - const entry = checkedOutHandle?.doc()?.at?.[target]; + const entry = checkedOutHandle.doc()?.at?.[target]; return { heads: entry?.from ?? null }; } + // Re-answer only the targets whose baseline actually moved: a re-send makes + // every editor on that doc recompute its diff. function notifyBaselines(): void { for (const [target, set] of baselineSubscribers) { const baseline = currentBaseline(target); + if (sameHeads(baseline.heads, sentBaselines.get(target) ?? null)) { + continue; + } + sentBaselines.set(target, baseline.heads); for (const respond of [...set]) respond(baseline); } } @@ -495,8 +586,8 @@ export const DraftStateProvider = (element: HTMLElement) => { if (skipVerdicts.get(url) === skipped) return; skipVerdicts.set(url, skipped); // A now-confirmed not-skipped doc may belong in the main draft. - syncMainDraftClones(); - recomputeList(); + scheduleCloneSync(); + reconcile(); } catch { // Leave unresolved: the doc keeps showing up, which is the safe default. } @@ -523,13 +614,24 @@ export const DraftStateProvider = (element: HTMLElement) => { return handle; } - // Keep the main draft's identity clone map in step with the live mounted set: - // every confirmed not-skipped mounted doc gets an identity entry (`cloneUrl - // === url`, empty fork heads). Additive only — entries are never removed, so - // main's membership (and history) is stable across unmounts. Writes are - // diffed, so this is a no-op once everything mounted is already recorded. + // Mounts and skip verdicts arrive one doc at a time; batch them into a single + // main-draft write (each write would otherwise rewalk and regroup). + function scheduleCloneSync(): void { + if (cloneSyncTimer !== null) return; + cloneSyncTimer = setTimeout(() => { + cloneSyncTimer = null; + syncMainDraftClones(); + }, 0); + } + + // Keep the main draft's identity clone map in step with the live mounted set + // while the sidebar is open: every confirmed not-skipped mounted doc gets an + // identity entry (`cloneUrl === url`, empty fork heads). Additive only — + // entries are never removed, so main's membership (and history) is stable + // across unmounts. Writes are diffed, so this is a no-op once everything + // mounted is already recorded. function syncMainDraftClones(): void { - if (disposed || !mainDraftHandle) return; + if (disposed || !mainDraftHandle || listSubscribers.size === 0) return; const existing = mainDraftHandle.doc()?.clones ?? {}; const toAdd = [...mountCounts.keys()].filter( (url) => skipVerdicts.get(url) === false && !existing[url] @@ -551,7 +653,8 @@ async function collectAllDrafts( repo: Repo, roots: readonly AutomergeUrl[], tracked: Map>, - onNewChange: () => void + onNewChange: () => void, + isDisposed: () => boolean ): Promise { const visited = new Set(); const order: AutomergeUrl[] = []; @@ -565,6 +668,7 @@ async function collectAllDrafts( let h = tracked.get(url); if (!h) { h = await repo.find(url); + if (isDisposed()) return order; tracked.set(url, h); h.on("change", onNewChange); }