From 6d2ffe91c9b2065d35c3e6cff113500d8ad97b5a Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 22 Jul 2026 14:07:21 +0200 Subject: [PATCH 01/18] Revert "revert draft overlay to one-shot handle descriptors" This reverts commit 9a65903b8869648478246a2bd4419610b3f3d696. --- drafts/package.json | 2 +- drafts/src/providers/DraftOverlayProvider.ts | 215 ++++++++++++------ threepane/src/components/DocumentAreaRoot.tsx | 125 +++++----- 3 files changed, 203 insertions(+), 139 deletions(-) diff --git a/drafts/package.json b/drafts/package.json index dc7c5c58..2a5a4366 100644 --- a/drafts/package.json +++ b/drafts/package.json @@ -15,7 +15,7 @@ "@automerge/automerge-repo": "^2.6.0-subduction.26", "@inkandswitch/patchwork-elements": "1.0.0", "@inkandswitch/patchwork-plugins": "^0.0.11", - "@inkandswitch/patchwork-providers": "0.2.2", + "@inkandswitch/patchwork-providers": "0.3.0", "@inkandswitch/patchwork-providers-solid": "0.2.3", "solid-automerge": "^2.0.0", "solid-js": "^1.9.9" diff --git a/drafts/src/providers/DraftOverlayProvider.ts b/drafts/src/providers/DraftOverlayProvider.ts index 12e9c228..01313413 100644 --- a/drafts/src/providers/DraftOverlayProvider.ts +++ b/drafts/src/providers/DraftOverlayProvider.ts @@ -9,58 +9,45 @@ import { import { accept, subscribe, + type DocHandleDescriptor, type SubscribeEvent, } from "@inkandswitch/patchwork-providers"; -// Shape of a `repo:handle-descriptor` answer. The one-shot host's -// patchwork-providers (0.2.2) does not export this type, so it is declared -// locally; it must stay in sync with the host's `OverlayRepo`. -type DocHandleDescriptor = { - url: AutomergeUrl; - cloneUrl?: AutomergeUrl; -}; - import type { CheckedOutDraft, DraftDoc } from "../draft-types.js"; import { SKIPPED_DATATYPES, canonicalUrl } from "../clone-policy.js"; const HANDLE_DESCRIPTOR_SELECTOR = "repo:handle-descriptor"; const CHECKED_OUT_SELECTOR = "draft:checked-out"; -// Mounts on a draft URL and remaps documents resolved beneath it onto -// per-draft clones, so edits stay inside the draft. +// Remaps documents resolved beneath it onto per-draft clones, so edits stay +// inside the checked-out draft — and re-points *live* in place when the +// selection changes, without the host remounting anything. +// +// The host `` wraps tool document resolution in an +// `OverlayRepo` that opens a *streaming* `repo:handle-descriptor` subscription +// per document. This provider always claims those subscriptions (even while +// "main" is selected, where it answers a pass-through `{ url }`) and keeps the +// `respond` callbacks registered. It follows the selection itself via the +// ancestor draft-list provider's `draft:checked-out` doc: when +// `CheckedOutDraft.checkedOut` changes, every live subscription is re-answered +// with the new mapping — `{ url, cloneUrl }` on a draft (the clone is forked +// eagerly on first resolution and recorded in `DraftDoc.clones`), `{ url }` on +// main — and the `OverlayRepo` swaps handle backings in place. // -// Under the new provider model the host `` wraps legacy tool -// document resolution in an `OverlayRepo` that asks the provider tree for a -// `repo:handle-descriptor` descriptor. This provider answers with -// `{ url, cloneUrl }`: the clone is forked eagerly the first time a document is -// requested in this draft (recorded in `DraftDoc.clones`), and the editor then -// reads and writes the clone while still reporting the original url. The fork -// point lives in `DraftDoc.clones[url].clonedAt`; the draft-list provider reads -// it to serve `draft:baseline` (this provider no longer answers that). +// Descriptors also honor the active checkpoint: `CheckedOutDraft.at` maps each +// member doc to per-doc `to`/`from` heads, and the `to` heads are baked onto +// the backing url (the clone on a draft, the original on main) so nested views +// freeze with the doc they live in; `OverlayRepo` honors heads on the backing +// url. The fork point lives in `DraftDoc.clones[url].clonedAt`; the draft-list +// provider reads it to serve `draft:baseline` (this provider no longer answers +// that). // -// On "main" (empty `url`) there is no clone remapping, but the provider still -// answers `repo:handle-descriptor` so that an active checkpoint can pin nested -// docs: it returns the original url carrying the checkpoint's `to` heads when one -// is set. Pinning also applies on a draft, baked onto the clone url. The per-doc -// `to`/`from` heads live on the checked-out draft (`CheckedOutDraft.at`), read -// here via the `draft:checked-out` subscription. A non-empty but invalid `url` -// is a misconfiguration and disables the provider. +// A `url` attribute, when present, seeds the initial selection. That is how +// the chat preview iframe (see chat's `preview-frame.ts`) pins a +// self-bootstrapped overlay to a specific draft in a realm that has no +// draft-list provider to follow. The *current* selection is reflected onto the +// (un-observed, so remount-free) `draft-url` attribute for outside readers. export const DraftOverlayProvider = (element: HTMLElement) => { - const rawUrl = element.getAttribute("url"); - // Empty `url` = "main": no clone remapping, but we still answer - // `repo:handle-descriptor` so an active checkpoint can pin nested docs. - let draftUrl: AutomergeUrl | null = null; - if (rawUrl) { - if (!isValidAutomergeUrl(rawUrl)) { - console.warn( - `[drafts] ` + - `has an invalid url attribute (got ${JSON.stringify(rawUrl)})` - ); - return () => {}; - } - draftUrl = rawUrl; - } - const repo = "repo" in window ? window.repo : undefined; if (!repo) { console.warn( @@ -72,33 +59,60 @@ export const DraftOverlayProvider = (element: HTMLElement) => { let disposed = false; + // The checked-out draft this overlay currently maps onto. Null = "main" + // (pass-through descriptors, save for checkpoint pinning). + let draftUrl: AutomergeUrl | null = null; + let draftReady: Promise> | null = null; + // Bumped on every re-point so in-flight resolutions from a superseded + // selection can detect they lost the race and stay silent. + let switchEpoch = 0; + // One eager-clone resolution per original url; de-dupes concurrent requests. + // Cleared on re-point (it is per-draft state). const cloneResolutions = new Map>(); - // The draft doc backing this overlay (clone bookkeeping). Null on "main". - const ready: Promise> | null = draftUrl - ? (async () => { - const handle = await liveRepo.find(draftUrl); - if (disposed) throw new Error("[drafts] provider disposed mid-load"); - return handle; - })() - : null; - ready?.catch((err) => { - console.error(`[drafts] failed to load draft overlay for ${draftUrl}:`, err); - }); - - // Track the checked-out draft's checkpoint so descriptors can pin a nested doc - // to its per-doc `to` heads. The url is served by the ancestor draft-list - // provider; we read `at[original].to` live at resolve time (one-shot, so we - // never block on this — absent means render live). + // Live `repo:handle-descriptor` subscriptions, kept so a re-point can push + // fresh descriptors to every consumer. + type DescriptorSubscriber = { + original: AutomergeUrl; + respond: (descriptor: DocHandleDescriptor) => void; + }; + const descriptorSubscribers = new Set(); + + // Seed the selection from the `url` attribute when present (the chat + // preview iframe mounts us with a pinned draft and no draft-list provider). + const rawSeed = element.getAttribute("url"); + if (rawSeed) { + if (isValidAutomergeUrl(rawSeed)) { + void applyDraft(rawSeed); + } else { + console.warn( + `[drafts] ` + + `has an invalid url attribute (got ${JSON.stringify(rawSeed)})` + ); + } + } + + // Follow the selection: the ancestor draft-list provider serves the + // ephemeral CheckedOutDraft doc url; we watch its `checkedOut` live. The + // same doc carries the checkpoint (`at`), read at resolve time so + // descriptors can pin a nested doc to its per-doc `to` heads — absent means + // render live. let checkedOutHandle: DocHandle | null = null; + const onCheckedOutChange = () => { + void applyDraft(checkedOutHandle?.doc()?.checkedOut ?? null); + }; const unsubscribeCheckedOut = subscribe( element, { type: CHECKED_OUT_SELECTOR }, (url) => { if (disposed || !isValidAutomergeUrl(url)) return; void liveRepo.find(url).then((handle) => { - if (!disposed) checkedOutHandle = handle; + if (disposed || checkedOutHandle === handle) return; + checkedOutHandle?.off("change", onCheckedOutChange); + checkedOutHandle = handle; + handle.on("change", onCheckedOutChange); + onCheckedOutChange(); }); } ); @@ -113,10 +127,22 @@ export const DraftOverlayProvider = (element: HTMLElement) => { } const original = canonicalUrl(rawTarget); accept(event, (respond) => { - void resolveDescriptor(original).then((descriptor) => { - if (disposed) return; - respond(descriptor); - }); + const subscriber: DescriptorSubscriber = { original, respond }; + descriptorSubscribers.add(subscriber); + const epoch = switchEpoch; + void resolveDescriptor(original) + .then((descriptor) => { + // A re-point raced this resolution; `applyDraft`'s refresh pass + // answers this subscriber with the new mapping instead. + if (disposed || epoch !== switchEpoch) return; + respond(descriptor); + }) + .catch((err) => { + console.error(`[drafts] failed to resolve ${original}:`, err); + }); + return () => { + descriptorSubscribers.delete(subscriber); + }; }); return; } @@ -127,12 +153,60 @@ export const DraftOverlayProvider = (element: HTMLElement) => { disposed = true; element.removeEventListener("patchwork:subscribe", onSubscribe); unsubscribeCheckedOut(); + checkedOutHandle?.off("change", onCheckedOutChange); + checkedOutHandle = null; + descriptorSubscribers.clear(); cloneResolutions.clear(); }; - // Resolve a `repo:handle-descriptor` request. The backing url is pinned to the - // active checkpoint's `to` heads for this doc (if any) so nested views freeze - // with the doc they live in; `OverlayRepo` honors heads on the backing url. + // Re-point the overlay at a new selection in place: reset the per-draft + // state, then push fresh descriptors to every live subscriber. + async function applyDraft(next: AutomergeUrl | null): Promise { + if (disposed) return; + if (next === draftUrl) return; + + draftUrl = next; + const epoch = ++switchEpoch; + cloneResolutions.clear(); + + // Reflect the selection for outside readers (e.g. the chat preview frame). + // `draft-url` is not observed by , so this never remounts. + element.setAttribute("draft-url", next ?? ""); + + draftReady = next + ? (async () => { + const handle = await liveRepo.find(next); + if (disposed) { + throw new Error("[drafts] provider disposed mid-load"); + } + return handle; + })() + : null; + draftReady?.catch((err) => { + console.error(`[drafts] failed to load draft overlay for ${next}:`, err); + }); + + // Re-answer every live descriptor subscription against the new selection. + // Each resolution is epoch-guarded so a rapid follow-up switch wins. + for (const subscriber of [...descriptorSubscribers]) { + void resolveDescriptor(subscriber.original) + .then((descriptor) => { + if (disposed || epoch !== switchEpoch) return; + subscriber.respond(descriptor); + }) + .catch((err) => { + console.error( + `[drafts] failed to re-map ${subscriber.original}:`, + err + ); + }); + } + } + + // Resolve a `repo:handle-descriptor` request against the current selection. + // The backing url is pinned to the active checkpoint's `to` heads for this + // doc (if any) so nested views freeze with the doc they live in; + // `OverlayRepo` honors heads on the backing url. // - On "main" (no draft): no clone, just the (maybe pinned) original. // - Skipped docs (account, contacts): the real doc, never forked. // - Everything else on a draft: the per-draft clone (pinned when checked out). @@ -179,15 +253,18 @@ export const DraftOverlayProvider = (element: HTMLElement) => { } } - // Ensure a clone of `original` exists for this draft and return its url. - // Reuses an existing clone recorded in `DraftDoc.clones`; otherwise forks - // `original` at its current heads and records the fork point so the baseline - // and merge-back can find it. + // Ensure a clone of `original` exists for the current draft and return its + // url. Reuses an existing clone recorded in `DraftDoc.clones`; otherwise + // forks `original` at its current heads and records the fork point so the + // baseline and merge-back can find it. function resolveClone(original: AutomergeUrl): Promise { const cached = cloneResolutions.get(original); if (cached) return cached; + const ready = draftReady; const promise = (async () => { - if (!ready) throw new Error("[drafts] resolveClone called without a draft"); + if (!ready) { + throw new Error("[drafts] resolveClone called without a draft"); + } const handle = await ready; const existing = handle.doc()?.clones?.[original]; if (existing) return canonicalUrl(existing.cloneUrl); diff --git a/threepane/src/components/DocumentAreaRoot.tsx b/threepane/src/components/DocumentAreaRoot.tsx index 419d8044..edbde17b 100644 --- a/threepane/src/components/DocumentAreaRoot.tsx +++ b/threepane/src/components/DocumentAreaRoot.tsx @@ -225,11 +225,11 @@ export function DocumentAreaRoot(props: DocumentAreaRootProps) { } // Renders the main document inside the draft-overlay provider. The provider -// mounts pinned to one draft (its `url` attribute) and answers document -// descriptors one-shot, so switching drafts works by remounting the provider -// subtree on the checked-out draft (see `draftProviderKey`). Checking a -// history entry in or out likewise remounts the main view (see `mainViewKey`), -// since nested docs resolve their checkpoint pin once, at mount. +// mounts once and follows the checked-out draft itself (via the draft-list +// provider's `draft:checked-out` doc), re-pointing live document handles in +// place — so a draft switch remounts nothing here. Only checking a history +// entry in or out remounts the main view (see `mainViewKey`), since nested +// docs resolve their checkpoint pin once, at mount. // // The comments + focus providers and the context (right) sidebar live *inside* // the overlay so that, on a draft, comment threads / selection resolve against @@ -256,14 +256,6 @@ function DraftDocumentArea(props: { type: "draft:checked-out", }); - // The overlay provider resolves descriptors one-shot against the draft in - // its `url` attribute, so the whole provider subtree is keyed on the - // selection: switching drafts remounts it and every descriptor is - // re-requested against the new draft. - const draftProviderKey = createMemo( - () => checkedOut()?.checkedOut ?? "main" - ); - // Registry-driven: whether the context sidebar exists at all (any tabs) — no // longer depends on any per-account config, just on whether anything is // currently tagged `context-tool`. The system tray is separate host chrome in @@ -323,67 +315,62 @@ function DraftDocumentArea(props: { ); return ( - - {(key) => ( + + - - - -
-
- - props.setIsRightSidebarCollapsed((v) => !v) - } - /> + +
+
+ + props.setIsRightSidebarCollapsed((v) => !v) + } + /> -
- props.setMainDocElement?.(el)} - /> -
-
- - - props.setIsRightSidebarCollapsed(true)} - /> - +
+ props.setMainDocElement?.(el)} + />
+
+ + + props.setIsRightSidebarCollapsed(true)} + /> - - +
+ - )} - + + ); } From 9bc73764a5975b92be9d19552fd82146c5488515 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 22 Jul 2026 14:07:29 +0200 Subject: [PATCH 02/18] Revert "drop leftover scopeReplaced handling from the reverted overlay re-pointing" This reverts commit c2a282cf73e7e9e9a9fc7c45ffd649822ba7cf1a. --- .../src/lib/extensions/automergeSync.ts | 45 +++++++++++++------ tldraw4/src/lith/useAutomergeStore.ts | 39 ++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/codemirror-base/src/lib/extensions/automergeSync.ts b/codemirror-base/src/lib/extensions/automergeSync.ts index 16ebf12b..421fee74 100644 --- a/codemirror-base/src/lib/extensions/automergeSync.ts +++ b/codemirror-base/src/lib/extensions/automergeSync.ts @@ -1,4 +1,4 @@ -import { createEffect } from "solid-js"; +import { createEffect, onCleanup } from "solid-js"; /** CodeMirror */ import { EditorView } from "@codemirror/view"; @@ -7,14 +7,17 @@ import { Compartment } from "@codemirror/state"; /** Automerge */ import type { Prop as AutomergeProp } from "@automerge/automerge"; import { automergeSyncPlugin } from "@automerge/automerge-codemirror"; -import type { DocHandle } from "@automerge/automerge-repo"; +import type { + DocHandle, + DocHandleChangePayload, +} from "@automerge/automerge-repo"; /** * Create a CodeMirror extension for synchronizing with an Automerge document using a CodeMirror Compartment. * @param handle The Automerge document handle. * @param path The path to the specific document property to synchronize. - * @returns A tuple containing the extension and a function to create an effect for reconfiguring the extension when the handle or path change. + * @returns A tuple containing the extension and a function to create an effect for reconfiguring the extension when the handle or path change (or the handle's backing is swapped in place). */ export function createSyncExtension( handle: () => DocHandle, @@ -37,16 +40,32 @@ export function createSyncExtension( // plugin re-seeds its reconciled heads from the current doc. const createReconfigureEffect = (view: EditorView) => createEffect(() => { - // The `handle()`/`path()` reads inside `syncExtension` are tracked, so a - // reactive prop change re-runs this and rebuilds the plugin. - view.dispatch({ - effects: sync.reconfigure(syncExtension()), - changes: { - from: 0, - to: view.state.doc.length, - insert: initialDoc(), - }, - }); + const rebuild = () => { + view.dispatch({ + effects: sync.reconfigure(syncExtension()), + changes: { + from: 0, + to: view.state.doc.length, + insert: initialDoc(), + }, + }); + }; + // Runs inside the effect, so the `handle()`/`path()` reads are tracked + // and a reactive prop change re-runs this whole wiring. + rebuild(); + + // The draft overlay can re-point a live handle at a different clone + // without the handle identity changing (a `change` event with + // `scopeReplaced: true`). The sync plugin diffs incrementally from its + // reconciled heads, which don't exist in the new backing's history — so + // rebuild the plugin (and the editor content) from the swapped-in doc. + const h = handle(); + if (!h) return; + const onChange = (payload: DocHandleChangePayload) => { + if (payload.scopeReplaced) rebuild(); + }; + h.on("change", onChange); + onCleanup(() => h.off("change", onChange)); }); return [sync.of(syncExtension()), createReconfigureEffect] as const; diff --git a/tldraw4/src/lith/useAutomergeStore.ts b/tldraw4/src/lith/useAutomergeStore.ts index 5ee2250d..86ffde5e 100644 --- a/tldraw4/src/lith/useAutomergeStore.ts +++ b/tldraw4/src/lith/useAutomergeStore.ts @@ -82,8 +82,47 @@ export function useAutomergeStore({ /* Automerge to TLDraw */ const syncAutomergeDocChangesToStore = ({ patches, + scopeReplaced, }: DocHandleChangePayload) => { if (preventPatchApplications) return; + + // A wholesale scope replacement (e.g. the draft overlay re-pointing + // this handle at a different clone) carries no patch stream connecting + // the old doc to the new one. Diff the new doc's *document*-scope + // records into the store rather than `loadStoreSnapshot`: that would + // `clear()` the session-scope records too (camera/zoom, current page, + // selection), which should survive a draft switch. Drafts are forks of + // each other, so most records are identical and `put` skips them. + if (scopeReplaced) { + const doc = handle.doc(); + if (!doc?.store) return; + const migrated = store.schema.migrateStoreSnapshot({ + store: JSON.parse(JSON.stringify(doc.store)), + schema: JSON.parse(JSON.stringify(doc.schema)), + }); + if (migrated.type === "error") { + console.error( + "[tldraw4] failed to migrate swapped-in snapshot:", + migrated.reason + ); + return; + } + const next = migrated.value; + store.mergeRemoteChanges(() => { + const toRemove = store + .allRecords() + .filter( + (record) => + store.scopedTypes.document.has(record.typeName) && + !(record.id in next) + ) + .map((record) => record.id); + if (toRemove.length) store.remove(toRemove); + store.put(Object.values(next)); + }); + return; + } + applyAutomergePatchesToTLStore(patches, store); }; From 10894a4615826f32f329a816502a39fd205e362d Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 22 Jul 2026 14:14:41 +0200 Subject: [PATCH 03/18] update lockfile for patchwork-providers 0.3.0 in drafts --- pnpm-lock.yaml | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf1bf813..504b7b6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,13 +504,13 @@ importers: version: 2.6.0-subduction.34(supports-color@7.2.0) '@inkandswitch/patchwork-elements': specifier: 1.0.0 - version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@7.2.0) + version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@7.2.0) '@inkandswitch/patchwork-plugins': specifier: ^0.0.11 version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.34(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.34(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.2.2 - version: 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)) + specifier: 0.3.0 + version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)) '@inkandswitch/patchwork-providers-solid': specifier: 0.2.3 version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(solid-js@1.9.13) @@ -8138,19 +8138,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@7.2.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)) - debug: 4.4.3(supports-color@7.2.0) - optionalDependencies: - '@types/react': 18.3.31 - react: 18.3.1 - solid-js: 1.9.13 - transitivePeerDependencies: - - supports-color - '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) From 082aa579773162c309fb86ee46f2fe92a8d86f4e Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Thu, 23 Jul 2026 09:32:42 +0200 Subject: [PATCH 04/18] scrub history without remounting: stream checkpoint pins through descriptors Checkpoint-only moves (scrubbing rewrites CheckedOutDraft.at) now re-answer every live repo:handle-descriptor subscription with freshly pinned backing urls, so OverlayRepo swaps handle backings in place instead of the main view remounting. DocumentAreaRoot drops pinnedDocUrl and keys the main view on the plain selected doc url. Stale in-flight resolutions are gated by a per-batch AbortController (replacing the switchEpoch counter). --- drafts/src/providers/DraftOverlayProvider.ts | 53 ++++++++++---- threepane/src/components/DocumentAreaRoot.tsx | 71 ++++--------------- 2 files changed, 55 insertions(+), 69 deletions(-) diff --git a/drafts/src/providers/DraftOverlayProvider.ts b/drafts/src/providers/DraftOverlayProvider.ts index 01313413..1b4157ec 100644 --- a/drafts/src/providers/DraftOverlayProvider.ts +++ b/drafts/src/providers/DraftOverlayProvider.ts @@ -38,9 +38,11 @@ const CHECKED_OUT_SELECTOR = "draft:checked-out"; // member doc to per-doc `to`/`from` heads, and the `to` heads are baked onto // the backing url (the clone on a draft, the original on main) so nested views // freeze with the doc they live in; `OverlayRepo` honors heads on the backing -// url. The fork point lives in `DraftDoc.clones[url].clonedAt`; the draft-list -// provider reads it to serve `draft:baseline` (this provider no longer answers -// that). +// url. Checkpoint moves (history scrubbing rewrites `at` without changing +// `checkedOut`) also re-answer every live subscription, so pins update by +// swapping backings in place — no remount. The fork point lives in +// `DraftDoc.clones[url].clonedAt`; the draft-list provider reads it to serve +// `draft:baseline` (this provider no longer answers that). // // A `url` attribute, when present, seeds the initial selection. That is how // the chat preview iframe (see chat's `preview-frame.ts`) pins a @@ -63,9 +65,12 @@ export const DraftOverlayProvider = (element: HTMLElement) => { // (pass-through descriptors, save for checkpoint pinning). let draftUrl: AutomergeUrl | null = null; let draftReady: Promise> | null = null; - // Bumped on every re-point so in-flight resolutions from a superseded - // selection can detect they lost the race and stay silent. - let switchEpoch = 0; + // Aborted and replaced on every descriptor refresh (draft re-point or + // checkpoint move), and aborted on dispose, so in-flight resolutions from a + // superseded state can detect they lost the race and stay silent. The + // signal only gates `respond` — it is never passed into the resolution + // work, which is shared across batches (see `cloneResolutions`). + let refresh = new AbortController(); // One eager-clone resolution per original url; de-dupes concurrent requests. // Cleared on re-point (it is per-draft state). @@ -98,9 +103,23 @@ export const DraftOverlayProvider = (element: HTMLElement) => { // same doc carries the checkpoint (`at`), read at resolve time so // descriptors can pin a nested doc to its per-doc `to` heads — absent means // render live. + // + // A draft switch re-points via `applyDraft` (resets per-draft state); a + // checkpoint-only move (scrubbing rewrites `at` while `checkedOut` stays + // put) just re-answers live subscriptions with freshly pinned descriptors. let checkedOutHandle: DocHandle | null = null; + let checkpointSignature = JSON.stringify(null); const onCheckedOutChange = () => { - void applyDraft(checkedOutHandle?.doc()?.checkedOut ?? null); + const doc = checkedOutHandle?.doc(); + const nextDraft = doc?.checkedOut ?? null; + const nextSignature = JSON.stringify(doc?.at ?? null); + const checkpointMoved = nextSignature !== checkpointSignature; + checkpointSignature = nextSignature; + if (nextDraft !== draftUrl) { + void applyDraft(nextDraft); + } else if (checkpointMoved) { + refreshDescriptors(); + } }; const unsubscribeCheckedOut = subscribe( element, @@ -129,12 +148,12 @@ export const DraftOverlayProvider = (element: HTMLElement) => { accept(event, (respond) => { const subscriber: DescriptorSubscriber = { original, respond }; descriptorSubscribers.add(subscriber); - const epoch = switchEpoch; + const { signal } = refresh; void resolveDescriptor(original) .then((descriptor) => { // A re-point raced this resolution; `applyDraft`'s refresh pass // answers this subscriber with the new mapping instead. - if (disposed || epoch !== switchEpoch) return; + if (signal.aborted) return; respond(descriptor); }) .catch((err) => { @@ -151,6 +170,7 @@ export const DraftOverlayProvider = (element: HTMLElement) => { element.addEventListener("patchwork:subscribe", onSubscribe); return () => { disposed = true; + refresh.abort(); element.removeEventListener("patchwork:subscribe", onSubscribe); unsubscribeCheckedOut(); checkedOutHandle?.off("change", onCheckedOutChange); @@ -166,7 +186,6 @@ export const DraftOverlayProvider = (element: HTMLElement) => { if (next === draftUrl) return; draftUrl = next; - const epoch = ++switchEpoch; cloneResolutions.clear(); // Reflect the selection for outside readers (e.g. the chat preview frame). @@ -186,12 +205,20 @@ export const DraftOverlayProvider = (element: HTMLElement) => { console.error(`[drafts] failed to load draft overlay for ${next}:`, err); }); - // Re-answer every live descriptor subscription against the new selection. - // Each resolution is epoch-guarded so a rapid follow-up switch wins. + refreshDescriptors(); + } + + // Re-answer every live descriptor subscription against the current + // selection and checkpoint. Aborts the previous batch so slower, superseded + // resolutions (including initial answers racing this refresh) stay silent. + function refreshDescriptors(): void { + refresh.abort(); + refresh = new AbortController(); + const { signal } = refresh; for (const subscriber of [...descriptorSubscribers]) { void resolveDescriptor(subscriber.original) .then((descriptor) => { - if (disposed || epoch !== switchEpoch) return; + if (signal.aborted) return; subscriber.respond(descriptor); }) .catch((err) => { diff --git a/threepane/src/components/DocumentAreaRoot.tsx b/threepane/src/components/DocumentAreaRoot.tsx index edbde17b..9bcf7457 100644 --- a/threepane/src/components/DocumentAreaRoot.tsx +++ b/threepane/src/components/DocumentAreaRoot.tsx @@ -15,13 +15,7 @@ * only reads `isLeftCollapsed` for top-bar layout. */ -import { - parseAutomergeUrl, - stringifyAutomergeUrl, - type AutomergeUrl, - type UrlHeads, -} from "@automerge/automerge-repo"; -import { subscribeDoc } from "@inkandswitch/patchwork-providers-solid"; +import { type AutomergeUrl } from "@automerge/automerge-repo"; import { makePersisted } from "@solid-primitives/storage"; import { createEffect, @@ -45,25 +39,6 @@ import { FrameTopBar } from "./FrameTopBar"; import { ContextSidebar } from "./ContextSidebar"; import { MainDocumentView } from "./MainDocumentView"; -// Mirrors the drafts package's `CheckedOutDraft`/`DraftCheckpoint`: the small -// writeable doc that holds the current selection plus an optional history pin. -// Each member doc's original url maps to the heads to view it at (`to`, which the -// frame reads to pin the doc) and to diff against (`from`, read by the drafts -// provider to serve `draft:baseline`). A doc absent from the map stays live. -type DocCheckpoint = { - from?: UrlHeads; - to?: UrlHeads; -}; - -type DraftCheckpoint = Record; - -type CheckedOutDraft = { - // `null` represents "main" — i.e. the host doc itself, no draft overlay. - checkedOut: AutomergeUrl | null; - // Absent/null = live latest heads; set = a frozen, read-only history view. - at?: DraftCheckpoint | null; -}; - const MIN_SIDEBAR_WIDTH = 180; const MAX_SIDEBAR_WIDTH = 720; // Drag a sidebar narrower than this and it snaps closed (Things-3 style). @@ -227,9 +202,10 @@ export function DocumentAreaRoot(props: DocumentAreaRootProps) { // Renders the main document inside the draft-overlay provider. The provider // mounts once and follows the checked-out draft itself (via the draft-list // provider's `draft:checked-out` doc), re-pointing live document handles in -// place — so a draft switch remounts nothing here. Only checking a history -// entry in or out remounts the main view (see `mainViewKey`), since nested -// docs resolve their checkpoint pin once, at mount. +// place — so neither a draft switch nor a history checkpoint move remounts +// anything here. Checkpoint pins travel on the *backing* url inside the +// streamed `repo:handle-descriptor` answers (see `DraftOverlayProvider`), so +// the presented doc url the view is keyed on stays stable while scrubbing. // // The comments + focus providers and the context (right) sidebar live *inside* // the overlay so that, on a draft, comment threads / selection resolve against @@ -252,10 +228,6 @@ function DraftDocumentArea(props: { selectedContextToolId: Accessor; setSelectedContextToolId: (id: string) => void; }) { - const [checkedOut] = subscribeDoc(props.host, { - type: "draft:checked-out", - }); - // Registry-driven: whether the context sidebar exists at all (any tabs) — no // longer depends on any per-account config, just on whether anything is // currently tagged `context-tool`. The system tray is separate host chrome in @@ -263,28 +235,15 @@ function DraftDocumentArea(props: { const contextItems = useTaggedComponents("context-tool"); const hasContext = () => contextItems().length > 0; - // When a history entry is checked out, view the selected doc at its pinned - // heads. The overlay reapplies these heads onto the draft's clone, yielding a - // fixed-heads (read-only) handle. No pin -> the live, canonical url. Keying - // the view on this url remounts the tool when entering/leaving a checkpoint. - const pinnedDocUrl = createMemo(() => { - const url = props.selectedDocUrl(); - if (!url) return url; - const { documentId } = parseAutomergeUrl(url); - const heads = checkedOut()?.at?.[stringifyAutomergeUrl({ documentId })]?.to; - return heads ? stringifyAutomergeUrl({ documentId, heads }) : url; - }); - - // Remount key for the main view. Nested docs (embeds, folder entries) resolve - // their checkpoint pin once, at mount, via `repo:handle-descriptor`; they only - // re-resolve when the subtree remounts. `pinnedDocUrl` alone misses checkpoint - // moves that don't change the main doc's own heads (e.g. an entry that only - // pins an embedded doc), so fold the whole `at` map into the key. - const mainViewKey = createMemo(() => { - const url = pinnedDocUrl(); - if (!url) return url; - return `${url}|${JSON.stringify(checkedOut()?.at ?? null)}`; - }); + // Remount key for the main view: just the selected doc. Checkpoint pins no + // longer ride on this url — the overlay provider streams them on the + // descriptors' *backing* urls and `OverlayRepo` swaps handle backings in + // place, so scrubbing history must not (and does not) change this key. + // Stamping heads here would also override the streamed pin: in + // `OverlayRepo`, heads on the presented url win over the backing's. + const mainViewKey = createMemo(() => + props.selectedDocUrl() + ); const [draftOverlayProviderHost, setDraftOverlayProviderHost] = createSignal(); @@ -344,7 +303,7 @@ function DraftDocumentArea(props: {
Date: Thu, 23 Jul 2026 11:37:50 +0200 Subject: [PATCH 05/18] reset editor undo stacks when a scope swap re-points the doc handle Scrubbing history / switching drafts swaps a live handle's backing (change event with scopeReplaced: true); the old undo entries describe a different timeline, so both editors now drop their stacks on that event: - codemirror-base owns history()/historyKeymap in a compartment (createHistoryExtension) and cycles it out and back in on scopeReplaced -- CodeMirror has no clear-history API, and single ownership matters because the history state field is a module-level singleton. codemirror-markdown no longer registers its own history. - the sync plugin's full-doc rebuild dispatch is annotated addToHistory: false + remote: true, so the swap itself is never undoable and the reset is order-independent. - tldraw4 clears the editor's stack via useClearHistoryOnScopeSwap in the automerge bindings (editor-level hook: the HistoryManager only exists once mounts, so this can't live in useAutomergeStore). Remote automerge patches entering the local undo history is a separate upstream issue (automerge-codemirror PR pending). --- codemirror-base/package.json | 1 + codemirror-base/src/lib/codemirror.tsx | 9 + .../src/lib/extensions/automergeSync.ts | 12 +- codemirror-base/src/lib/extensions/history.ts | 52 +++++ codemirror-base/src/lib/extensions/index.ts | 1 + .../src/extensions/markdown.ts | 14 +- pnpm-lock.yaml | 194 +++++++----------- tldraw4/src/lith/useAutomergeStore.ts | 27 +++ tldraw4/src/tool.tsx | 3 + 9 files changed, 188 insertions(+), 125 deletions(-) create mode 100644 codemirror-base/src/lib/extensions/history.ts diff --git a/codemirror-base/package.json b/codemirror-base/package.json index cdec6372..a66d5f91 100644 --- a/codemirror-base/package.json +++ b/codemirror-base/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@automerge/automerge-codemirror": "^0.2.0", + "@codemirror/commands": "^6.9.0", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.4", "@inkandswitch/patchwork-bootloader": "^0.2.8", diff --git a/codemirror-base/src/lib/codemirror.tsx b/codemirror-base/src/lib/codemirror.tsx index 8085b129..05e9a01c 100644 --- a/codemirror-base/src/lib/codemirror.tsx +++ b/codemirror-base/src/lib/codemirror.tsx @@ -12,6 +12,7 @@ import { createReadOnlyExtension, createDecorationsExtension, createDiffExtension, + createHistoryExtension, createScrollHighlightIntoViewExtension, } from "./extensions"; @@ -56,6 +57,12 @@ export function CodeMirror(props: CodeMirrorProps) { const [readOnlyExtension, createEffectReconfigureReadOnly] = createReadOnlyExtension(() => !!props.readOnly); + // Undo/redo lives here (not in tool-supplied extensions) so the stack can + // be reset when the handle's backing is swapped in place -- see history.ts. + const [historyExtension, createEffectResetHistory] = createHistoryExtension( + () => props.handle + ); + const [decorationsExtension, createEffectReconfigureDecorations] = createDecorationsExtension(() => props.decorations?.()); @@ -90,6 +97,7 @@ export function CodeMirror(props: CodeMirrorProps) { // syncExtension must come before diffExtension so diff stays in sync with edits. syncExtension, diffExtension, + historyExtension, scrollHighlightIntoViewExtension, userExtensionsCompartment.of(props.extensions || []), readOnlyExtension, @@ -111,6 +119,7 @@ export function CodeMirror(props: CodeMirrorProps) { createEffectReconfigureReadOnly(view); createEffectReconfigureDecorations?.(view); createEffectReconfigureDiff(view); + createEffectResetHistory(view); createEffectScrollHighlightIntoView(view); // Reconfigure user extensions when props.extensions changes diff --git a/codemirror-base/src/lib/extensions/automergeSync.ts b/codemirror-base/src/lib/extensions/automergeSync.ts index 421fee74..f9b6342f 100644 --- a/codemirror-base/src/lib/extensions/automergeSync.ts +++ b/codemirror-base/src/lib/extensions/automergeSync.ts @@ -2,7 +2,7 @@ import { createEffect, onCleanup } from "solid-js"; /** CodeMirror */ import { EditorView } from "@codemirror/view"; -import { Compartment } from "@codemirror/state"; +import { Compartment, Transaction } from "@codemirror/state"; /** Automerge */ import type { Prop as AutomergeProp } from "@automerge/automerge"; @@ -38,6 +38,12 @@ export function createSyncExtension( // instance is destroyed without seeing this transaction, so the full-doc // reset below is not echoed back into the automerge doc) and the fresh // plugin re-seeds its reconciled heads from the current doc. + // + // The reset is a synthetic remote transaction, not a user edit: it must not + // land on the undo stack (undoing it would restore a different timeline's + // text and write it back into the doc). Marking it also makes the history + // reset on `scopeReplaced` (see `createHistoryExtension`) order-independent + // across the two `change` listeners. const createReconfigureEffect = (view: EditorView) => createEffect(() => { const rebuild = () => { @@ -48,6 +54,10 @@ export function createSyncExtension( to: view.state.doc.length, insert: initialDoc(), }, + annotations: [ + Transaction.addToHistory.of(false), + Transaction.remote.of(true), + ], }); }; // Runs inside the effect, so the `handle()`/`path()` reads are tracked diff --git a/codemirror-base/src/lib/extensions/history.ts b/codemirror-base/src/lib/extensions/history.ts new file mode 100644 index 00000000..1306d28c --- /dev/null +++ b/codemirror-base/src/lib/extensions/history.ts @@ -0,0 +1,52 @@ +import { createEffect, onCleanup } from "solid-js"; + +/** CodeMirror */ +import { EditorView, keymap } from "@codemirror/view"; +import { Compartment } from "@codemirror/state"; +import { history, historyKeymap } from "@codemirror/commands"; + +/** Automerge */ +import type { + DocHandle, + DocHandleChangePayload, +} from "@automerge/automerge-repo"; + +/** + * Undo/redo history, owned by the base editor. Tools must not add their own + * `history()`: the underlying history state field is a module-level singleton + * in `@codemirror/commands`, so a second copy elsewhere in the configuration + * would keep the field alive across our reset and defeat it. + * + * The stack is reset whenever the handle's backing is swapped in place (a + * `change` event with `scopeReplaced: true` — e.g. scrubbing history or + * switching drafts re-points the handle at a different clone): the old + * entries describe a different timeline, and undo must only revert what the + * user has done since. CodeMirror has no clear-history API; the sanctioned + * reset is cycling the extension out of and back into the configuration, + * which drops the history field's state and re-creates it empty. + */ +export function createHistoryExtension(handle: () => DocHandle) { + const compartment = new Compartment(); + const historyExtension = () => [history(), keymap.of(historyKeymap)]; + + const createResetEffect = (view: EditorView) => + createEffect(() => { + const h = handle(); + if (!h) return; + const onChange = (payload: DocHandleChangePayload) => { + if (!payload.scopeReplaced) return; + // Two dispatches: the first removes the history field (dropping its + // state), the second re-adds it empty. The sync extension's rebuild + // for the same event is dispatched with `addToHistory: false`, so the + // relative order of the two `change` listeners doesn't matter. + view.dispatch({ effects: compartment.reconfigure([]) }); + view.dispatch({ + effects: compartment.reconfigure(historyExtension()), + }); + }; + h.on("change", onChange); + onCleanup(() => h.off("change", onChange)); + }); + + return [compartment.of(historyExtension()), createResetEffect] as const; +} diff --git a/codemirror-base/src/lib/extensions/index.ts b/codemirror-base/src/lib/extensions/index.ts index 4cf0ae56..c460577f 100644 --- a/codemirror-base/src/lib/extensions/index.ts +++ b/codemirror-base/src/lib/extensions/index.ts @@ -1,5 +1,6 @@ export { createDecorationsExtension } from "./decorations"; export { createReadOnlyExtension } from "./readOnly"; export { createSyncExtension } from "./automergeSync"; +export { createHistoryExtension } from "./history"; export { createDiffExtension } from "./diff"; export { createScrollHighlightIntoViewExtension } from "./scrollHighlightIntoView"; diff --git a/codemirror-markdown/src/extensions/markdown.ts b/codemirror-markdown/src/extensions/markdown.ts index dfb7d5a9..f71de211 100644 --- a/codemirror-markdown/src/extensions/markdown.ts +++ b/codemirror-markdown/src/extensions/markdown.ts @@ -1,11 +1,6 @@ /** CodeMirror Extensions */ import { completionKeymap } from "@codemirror/autocomplete"; -import { - defaultKeymap, - history, - historyKeymap, - indentWithTab, -} from "@codemirror/commands"; +import { defaultKeymap, indentWithTab } from "@codemirror/commands"; import { markdown } from "@codemirror/lang-markdown"; import { foldKeymap, indentOnInput, indentUnit } from "@codemirror/language"; import { languages } from "@codemirror/language-data"; @@ -15,15 +10,18 @@ import { EditorView, keymap } from "@codemirror/view"; /** Styles */ import { theme } from "../themes/markdown.ts"; +// Undo/redo (`history()`/`historyKeymap`) is deliberately absent: the base +// editor (codemirror-base) owns it, so it can reset the stack when a scope +// swap re-points the doc handle (see codemirror-base's history extension). +// Adding a second `history()` here would keep the underlying singleton state +// field alive across that reset and break it. export function markdownExtensions() { return [ ...theme("sans"), - history(), indentOnInput(), keymap.of([ ...defaultKeymap, ...searchKeymap, - ...historyKeymap, ...foldKeymap, ...completionKeymap, indentWithTab, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 504b7b6f..78791888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,41 +33,41 @@ importers: dependencies: '@inkandswitch/patchwork-providers': specifier: 0.3.0 - version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0)) + version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)) '@inkandswitch/patchwork-providers-solid': specifier: 0.2.3 - version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.2.6)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13) + version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.2.6)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(solid-js@1.9.13) devDependencies: '@automerge/automerge': specifier: ^3.2.5 version: 3.2.6 '@automerge/automerge-repo': specifier: ^2.6.0-subduction.9 - version: 2.6.0-subduction.34(supports-color@5.5.0) + version: 2.6.0-subduction.34(supports-color@7.2.0) '@babel/core': specifier: 7.28.4 - version: 7.28.4(supports-color@5.5.0) + version: 7.28.4(supports-color@7.2.0) '@babel/parser': specifier: 7.28.4 version: 7.28.4 '@inkandswitch/patchwork-bootloader': specifier: ^0.0.4 - version: 0.0.4(@automerge/automerge-repo-keyhive@0.0.0-alpha.71(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@automerge/vanillajs@2.5.0(supports-color@7.2.0))(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@5.5.0) + version: 0.0.4(@automerge/automerge-repo-keyhive@0.0.0-alpha.71(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@automerge/vanillajs@2.5.0(supports-color@7.2.0))(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@5.5.0) '@inkandswitch/patchwork-elements': specifier: ^0.0.6 - version: 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0))(supports-color@5.5.0) + version: 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0))(supports-color@5.5.0) '@inkandswitch/patchwork-filesystem': specifier: ^0.0.4 - version: 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0) + version: 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0) '@inkandswitch/patchwork-plugins': specifier: ^0.0.6 - version: 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0) + version: 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0) babel-preset-solid: specifier: ^1.9.9 - version: 1.9.12(@babel/core@7.28.4(supports-color@5.5.0))(solid-js@1.9.13) + version: 1.9.12(@babel/core@7.28.4(supports-color@7.2.0))(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(solid-js@1.9.13)(supports-color@7.2.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -205,6 +205,9 @@ importers: '@automerge/automerge-codemirror': specifier: ^0.2.0 version: 0.2.0 + '@codemirror/commands': + specifier: ^6.9.0 + version: 6.10.4 '@codemirror/state': specifier: ^6.5.2 version: 6.7.0 @@ -6557,24 +6560,6 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0)': - dependencies: - '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-subduction': 0.16.0 - bs58check: 4.0.0 - cbor-x: 1.6.4 - debug: 4.4.3(supports-color@5.5.0) - eventemitter3: 5.0.4 - fast-sha256: 1.3.0 - isomorphic-ws: 5.0.0(ws@8.21.0) - uuid: 14.0.1 - ws: 8.21.0 - xstate: 5.32.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 @@ -6771,11 +6756,11 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.4(supports-color@5.5.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.4(supports-color@5.5.0))(supports-color@5.5.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.28.4 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -6791,7 +6776,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.4(supports-color@5.5.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.4(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.28.4 '@babel/template': 7.29.7 @@ -6848,6 +6833,13 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)': + dependencies: + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) @@ -6855,9 +6847,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.4(supports-color@5.5.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.4(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/core': 7.28.4(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.4(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.28.4(supports-color@7.2.0) '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) @@ -6894,11 +6895,6 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.4(supports-color@5.5.0))': - dependencies: - '@babel/core': 7.28.4(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.4(supports-color@7.2.0))': dependencies: '@babel/core': 7.28.4(supports-color@7.2.0) @@ -6950,6 +6946,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7(supports-color@5.5.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -7763,30 +7771,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-bootloader@0.0.4(@automerge/automerge-repo-keyhive@0.0.0-alpha.71(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@automerge/vanillajs@2.5.0(supports-color@7.2.0))(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@5.5.0)': + '@inkandswitch/patchwork-bootloader@0.0.4(@automerge/automerge-repo-keyhive@0.0.0-alpha.71(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0(supports-color@7.2.0))(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@7.2.0)': dependencies: - '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) '@automerge/automerge-repo-keyhive': 0.0.0-alpha.71(supports-color@7.2.0) '@automerge/vanillajs': 2.5.0(supports-color@7.2.0) '@keyhive/keyhive': 0.0.0-alpha.40 '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@7.2.0) resolve.exports: 2.0.3 service-worker-types: '@types/serviceworker@0.0.153' tinyargs: 0.1.4 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-bootloader@0.0.4(@automerge/automerge-repo-keyhive@0.0.0-alpha.71(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0(supports-color@7.2.0))(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@7.2.0)': + '@inkandswitch/patchwork-bootloader@0.0.4(@automerge/automerge-repo-keyhive@0.0.0-alpha.71(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@automerge/vanillajs@2.5.0(supports-color@7.2.0))(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@5.5.0)': dependencies: - '@automerge/automerge': 3.2.1 + '@automerge/automerge': 3.2.6 '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) '@automerge/automerge-repo-keyhive': 0.0.0-alpha.71(supports-color@7.2.0) '@automerge/vanillajs': 2.5.0(supports-color@7.2.0) '@keyhive/keyhive': 0.0.0-alpha.40 '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@5.5.0) resolve.exports: 2.0.3 service-worker-types: '@types/serviceworker@0.0.153' tinyargs: 0.1.4 @@ -8060,15 +8068,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-elements@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0))(supports-color@5.5.0)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) - '@inkandswitch/patchwork-filesystem': 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0) - '@inkandswitch/patchwork-plugins': 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0) - debug: 4.4.3(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - '@inkandswitch/patchwork-elements@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0))(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) @@ -8078,6 +8077,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@inkandswitch/patchwork-elements@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0))(supports-color@5.5.0)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0) + '@inkandswitch/patchwork-plugins': 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + '@inkandswitch/patchwork-elements@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@7.2.0))(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) @@ -8266,24 +8274,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0)': + '@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0)': dependencies: - '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@7.2.0) resolve.exports: 2.0.3 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0)': + '@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0)': dependencies: - '@automerge/automerge': 3.2.1 + '@automerge/automerge': 3.2.6 '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@5.5.0) resolve.exports: 2.0.3 transitivePeerDependencies: - supports-color @@ -8573,27 +8581,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0)': + '@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) - '@inkandswitch/patchwork-filesystem': 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0) + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@7.2.0) eventemitter3: 5.0.4 resolve.exports: 2.0.3 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0))(supports-color@7.2.0)': + '@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: - '@automerge/automerge': 3.2.1 + '@automerge/automerge': 3.2.6 '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.0.4(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(@automerge/automerge@3.2.6)(supports-color@5.5.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@5.5.0) eventemitter3: 5.0.4 resolve.exports: 2.0.3 transitivePeerDependencies: @@ -8645,13 +8653,6 @@ snapshots: '@inkandswitch/patchwork-providers': 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0)) react: 18.3.1 - '@inkandswitch/patchwork-providers-solid@0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.2.6)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) - '@automerge/automerge-repo-solid-primitives': 2.6.0-subduction.39(@automerge/automerge@3.2.6)(solid-js@1.9.13) - '@inkandswitch/patchwork-providers': 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0)) - solid-js: 1.9.13 - '@inkandswitch/patchwork-providers-solid@0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.2.6)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(solid-js@1.9.13)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) @@ -8681,10 +8682,6 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.5.6(supports-color@5.5.0) - '@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) - '@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) @@ -8693,10 +8690,6 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39(supports-color@7.2.0) - '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) - '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) @@ -10692,15 +10685,6 @@ snapshots: postcss: 8.5.15 postcss-value-parser: 4.2.0 - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.4(supports-color@5.5.0)): - dependencies: - '@babel/core': 7.28.4(supports-color@5.5.0) - '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.4(supports-color@5.5.0)) - '@babel/types': 7.29.7 - html-entities: 2.3.3 - parse5: 7.3.0 - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.4(supports-color@7.2.0)): dependencies: '@babel/core': 7.28.4(supports-color@7.2.0) @@ -10723,13 +10707,6 @@ snapshots: dependencies: '@babel/types': 7.29.7 - babel-preset-solid@1.9.12(@babel/core@7.28.4(supports-color@5.5.0))(solid-js@1.9.13): - dependencies: - '@babel/core': 7.28.4(supports-color@5.5.0) - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.4(supports-color@5.5.0)) - optionalDependencies: - solid-js: 1.9.13 - babel-preset-solid@1.9.12(@babel/core@7.28.4(supports-color@7.2.0))(solid-js@1.9.13): dependencies: '@babel/core': 7.28.4(supports-color@7.2.0) @@ -10804,12 +10781,6 @@ snapshots: '@noble/hashes': 1.8.0 bs58: 6.0.0 - cabbages@0.2.8(supports-color@5.5.0): - dependencies: - debug: 4.4.3(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - cabbages@0.2.8(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -11940,15 +11911,6 @@ snapshots: transitivePeerDependencies: - supports-color - solid-automerge@2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0): - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) - '@solid-primitives/utils': 6.4.1(solid-js@1.9.13) - cabbages: 0.2.8(supports-color@5.5.0) - solid-js: 1.9.13 - transitivePeerDependencies: - - supports-color - solid-automerge@2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@7.2.0))(solid-js@1.9.13)(supports-color@7.2.0): dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@7.2.0) @@ -12207,7 +12169,7 @@ snapshots: dependencies: '@babel/core': 7.28.4(supports-color@7.2.0) '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.28.4(supports-color@5.5.0))(solid-js@1.9.13) + babel-preset-solid: 1.9.12(@babel/core@7.28.4(supports-color@7.2.0))(solid-js@1.9.13) merge-anything: 5.1.7 solid-js: 1.9.13 solid-refresh: 0.6.3(solid-js@1.9.13)(supports-color@7.2.0) diff --git a/tldraw4/src/lith/useAutomergeStore.ts b/tldraw4/src/lith/useAutomergeStore.ts index 86ffde5e..c176a3b6 100644 --- a/tldraw4/src/lith/useAutomergeStore.ts +++ b/tldraw4/src/lith/useAutomergeStore.ts @@ -14,6 +14,7 @@ import { react, type TLStoreSnapshot, sortById, + useEditor, } from "@tldraw/tldraw"; import { useEffect, useState } from "react"; import { @@ -157,6 +158,32 @@ export function useAutomergeStore({ return storeWithStatus; } +// A scope swap (a `change` event with `scopeReplaced: true` -- the draft +// overlay re-pointing this handle at a different clone when scrubbing history +// or switching drafts) invalidates the undo stack: its entries describe a +// different timeline. The swapped-in records themselves are applied via +// `mergeRemoteChanges` in `useAutomergeStore` above (so they are never +// recorded); clearing the stale stack is all that's left to do. +// +// This can't live in `useAutomergeStore` directly: undo history is not a +// store concern -- the `HistoryManager` belongs to the `Editor`, which tldraw +// only creates internally once `` mounts. The store hook +// runs outside ``, before any editor exists, so the history-clearing +// half of the swap handling has to be a separate hook rendered *inside* +// ``, where `useEditor()` can reach the editor. +export function useClearHistoryOnScopeSwap(handle: DocHandle) { + const editor = useEditor(); + + useEffect(() => { + if (!editor) return; + const onChange = (payload: DocHandleChangePayload) => { + if (payload.scopeReplaced) editor.clearHistory(); + }; + handle.on("change", onChange); + return () => void handle.off("change", onChange); + }, [editor, handle]); +} + export function useAutomergePresence({ handle, store, diff --git a/tldraw4/src/tool.tsx b/tldraw4/src/tool.tsx index e1926dfb..c1eea96e 100644 --- a/tldraw4/src/tool.tsx +++ b/tldraw4/src/tool.tsx @@ -23,6 +23,7 @@ import { import { useAutomergeStore, useAutomergePresence, + useClearHistoryOnScopeSwap, } from "./lith/useAutomergeStore.ts"; import type { TLDrawDoc } from "./datatype.ts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -293,8 +294,10 @@ function TldrawInner(props: { const editor = useEditor(); const repo = useRepo(); + const handle = useDocHandle(props.docUrl, { suspense: true }); usePatchworkDrop(props.element); + useClearHistoryOnScopeSwap(handle); const onChange = useCallback(() => { if (!editor) return; From 9dfbfc881be7c5c24ed07aabc7443d30b556cab6 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Fri, 24 Jul 2026 09:52:33 +0200 Subject: [PATCH 06/18] tweak draft scrubber: black marker on top, drag-to-top returns to latest Paint the timeline indicator (dot + line) on top of the group rows and always draw it in the foreground color. Drop the "current" badge; in its place the card title shows a "Return to latest" pill while the timeline is pinned. Dragging the scrubber all the way to the top now returns to the live latest version instead of freezing at the newest change. --- drafts/src/DraftsSidebar.tsx | 107 ++++++++++++++++++----------------- drafts/src/styles.css | 57 +++++++------------ 2 files changed, 74 insertions(+), 90 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index f5fd9daa..bed583f1 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -40,7 +40,7 @@ const EMPTY_DRAFT_LIST: DraftList = { }; // Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.18"; +const DRAFTS_VERSION = "0.0.20"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -351,7 +351,9 @@ export function DraftsSidebar(props: { element: HTMLElement }) { name={summary.name} onRename={(name) => void onRename(summary.url, name)} onSelect={selectDraft} - onScrub={(scrub) => onScrub(summary.url, summary.members, scrub)} + onScrub={(scrub) => + onScrub(summary.url, summary.members, scrub) + } scrubber={() => selected() === summary.url ? scrubber() : null } @@ -360,9 +362,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { head ? { members: summary.members, head } : null ) } - hasCheckpoint={ - selected() === summary.url && !!checkedOut()?.at - } + hasCheckpoint={selected() === summary.url && !!checkedOut()?.at} onReturnToLatest={clearCheckpoint} /> )} @@ -487,10 +487,7 @@ function cloneAtVersion( } const bundle = Automerge.saveBundle(doc, [...closure]); - const pinned = Automerge.loadIncremental( - Automerge.init(), - bundle - ); + const pinned = Automerge.loadIncremental(Automerge.init(), bundle); const gotHeads = [...Automerge.getHeads(pinned)].sort(); const wantHeads = [...pinHeads].sort(); @@ -670,8 +667,7 @@ function collectForkDiagnostic( level: f.level, head: f.head, memberCount: f.members.length, - topoRange: - min <= max ? ([min, max] as [number, number]) : null, + topoRange: min <= max ? ([min, max] as [number, number]) : null, containsPin, }; }); @@ -762,8 +758,22 @@ function MainCard(props: { fallback="Main" onRename={props.onRename} /> - - current + {/* Shown in the title (where the "current" badge used to sit) while + the timeline is pinned: drops the pin and returns to the live + latest heads. It lives inside the clickable header, so the click + is stopped from also re-selecting the card. */} + +
@@ -774,10 +784,7 @@ function MainCard(props: { onScrub={props.onScrub} scrubber={props.scrubber} onDragVersion={props.onDragVersion} - /> -
@@ -812,8 +819,19 @@ function DraftCard(props: { fallback="Draft" onRename={props.onRename} /> - - current + {/* See MainCard: pinned-only "return to latest" control in the title. */} + + @@ -824,34 +842,13 @@ function DraftCard(props: { onScrub={props.onScrub} scrubber={props.scrubber} onDragVersion={props.onDragVersion} - /> - ); } -// Card footer shown while the card's timeline is pinned to a checkpoint: -// drops the time pin and returns the view to the live latest heads. Rendered -// outside the scrollable changes area so it never scrolls out of reach. -function ReturnToLatestButton(props: { visible: boolean; onClick: () => void }) { - return ( - - - - ); -} - // A card's display name. Double-click to rename inline: Enter or clicking // away commits, Escape cancels, and committing an empty value clears the // name back to the default label. @@ -953,19 +950,21 @@ function changeRef(change: DraftChange): ChangeRef { // Renders a draft's (or main's) changes as a timeline of activity groups: // every member doc's changes interleaved newest first, then split wherever // the editing paused for INACTIVITY_GAP (see `groupChanges`). A gutter on -// the left spans the whole history (top = latest change, bottom = first); +// the left spans the whole history (top = latest version, bottom = first); // the indicator — a calendar-style dot + line — marks the version being -// looked at. Its line paints *under* the (semi-transparent) group rows; -// dragging starts only from its handles in the gutter. While pinned, a -// sticker overlays the row at the head with the exact change the line sits -// on. The member set is passed in (from the card's `DraftSummary`); the -// effect below keeps the timeline live as those docs edit. +// looked at and paints *on top* of everything in the changes area. +// Dragging starts only from its handles in the gutter; dragging it all the +// way to the top returns to the latest version (`onReturnToLatest`). While +// pinned, a sticker overlays the row at the head with the exact change the +// line sits on. The member set is passed in (from the card's `DraftSummary`); +// the effect below keeps the timeline live as those docs edit. function DraftChangesList(props: { members: Accessor; mainDocUrl: AutomergeUrl | undefined; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; onDragVersion: (head: ChangeRef | null) => void; + onReturnToLatest: () => void; }) { const [changes, setChanges] = createSignal([]); @@ -1157,7 +1156,9 @@ function DraftChangesList(props: { // the indicator was grabbed). Scrubbing starts only from the indicator's // own handles (dot, line) — the bare gutter and the rows don't scrub. // Every position snaps to an individual change, so the indicator can rest - // anywhere in history — between groups or in the middle of one. + // anywhere in history — between groups or in the middle of one. Dragging + // all the way to the top (the newest change) means "return to the latest + // version": it drops the pin rather than freezing at the newest change. const beginDrag = (ev: PointerEvent) => { if (!trackEl || visibleChanges().length === 0) return; ev.preventDefault(); @@ -1169,7 +1170,8 @@ function DraftChangesList(props: { const head = indexForY(yInTrack(e) - grabOffset); if (head === last) return; last = head; - scrubTo(head); + if (head === 0) props.onReturnToLatest(); + else scrubTo(head); }; const target = ev.currentTarget as HTMLElement; @@ -1235,20 +1237,19 @@ function DraftChangesList(props: {
- {/* The head line, painted under the group rows. */} + {/* The head line, painted on top of the group rows. */}
{/* Grab handle, confined to the gutter. */}
{/* Pinned inside a group: overlay the row with the exact diff --git a/drafts/src/styles.css b/drafts/src/styles.css index 6a261cab..b1d5137a 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -184,16 +184,6 @@ outline: none; } -.draft-badge { - flex-shrink: 0; - font-size: 0.625rem; - line-height: 1; - padding: 0.125rem 0.375rem; - border-radius: var(--studio-radius-round, 9999px); - background: var(--drafts-primary); - color: var(--drafts-primary-fg); -} - .draft-card-url { font-size: 0.75rem; font-family: var(--drafts-mono); @@ -222,19 +212,19 @@ max-height: 32rem; } -/* Card footer while the timeline is pinned: sits under the (scrollable) - changes area, so it stays reachable however long the history is. */ +/* Sits in the card title (where the "current" badge used to be) while the + timeline is pinned; drops the pin and returns to the latest version. */ .draft-card-return { - display: block; - width: 100%; - padding: 0.375rem 0.5rem; - border: none; - border-top: 1px solid var(--drafts-border); + flex-shrink: 0; + margin-left: auto; + padding: 0.125rem 0.5rem; + border: 1px solid var(--drafts-border); + border-radius: var(--studio-radius-round, 9999px); background: transparent; font-family: inherit; - font-size: 0.75rem; + font-size: 0.6875rem; + line-height: 1.2; color: var(--drafts-primary); - text-align: center; cursor: pointer; } @@ -332,8 +322,8 @@ cursor: pointer; } -/* Group backgrounds stay semi-transparent so the scrubber lines painted - underneath the rows remain visible through them. */ +/* Group backgrounds stay lightly translucent so the row highlight reads as a + soft wash rather than a hard block. */ .draft-group-row:hover { background: color-mix(in srgb, var(--drafts-hover-bg) 60%, transparent); } @@ -385,8 +375,8 @@ /* Two-column body inside the scrollable changes area: gutter + group rows, with the indicator overlaid across both. Positioned so the rows' offsetTop measurements (used to map changes onto the gutter) are relative - to this box. Isolated so the indicator's negative-z lines paint above the - card background yet below the rows (instead of escaping behind the card). */ + to this box, and isolated so the indicator's own stacking context stays + contained to this card. */ .draft-changes-body { position: relative; isolation: isolate; @@ -413,25 +403,19 @@ } /* The indicator sits at the head like a calendar's now-line: a dot in the - gutter plus a line across the rows (the version being looked at). The line - paints *under* the group rows (negative z-index; the rows' backgrounds are - semi-transparent), while the handles and the sticker paint above. The box - itself ignores pointer events so rows stay clickable; the handles - re-enable them. */ + gutter plus a line across the rows (the version being looked at). It paints + on top of everything else in the changes area (high z-index), always drawn + in the foreground color. The box itself ignores pointer events so rows stay + clickable; the handles re-enable them. */ .draft-scrubber-token { - --scrub-color: var(--studio-danger, #dc2626); + --scrub-color: var(--drafts-fg); position: absolute; left: 0; right: 0; + z-index: 20; pointer-events: none; } -/* Nothing pinned: the indicator idles at the top (live latest), drawn - muted. */ -.draft-scrubber-token[data-live] { - --scrub-color: var(--drafts-faint-fg); -} - .draft-scrubber-dot { position: absolute; top: -5px; @@ -449,7 +433,7 @@ cursor: grabbing; } -/* The visible head line, spanning into the rows but painted behind them. */ +/* The visible head line, spanning across the rows on top of them. */ .draft-scrubber-line { position: absolute; top: 0; @@ -459,7 +443,6 @@ margin-top: -1px; border-radius: 1px; background: var(--scrub-color); - z-index: -1; } /* Invisible grab strip over the line's gutter end; dragging only starts From 04fc7c837cb40e2170e8500476e023c7daff7940 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Fri, 24 Jul 2026 15:27:56 +0200 Subject: [PATCH 07/18] cache draft timeline groups: chunked background fill into per-draft cache docs The sidebar re-ran Automerge.diff over every change on each edit to group history, blocking the main thread. A fill engine now computes activity groups in idle-time slices and persists them in a change-group-cache doc linked from each draft; the sidebar renders straight from the cache and resolves scrub positions on demand from change metadata (no diffs). --- drafts/src/DraftsSidebar.tsx | 677 ++++++++----------- drafts/src/change-group-cache.ts | 785 ++++++++++++++++++++++ drafts/src/clone-policy.ts | 1 + drafts/src/draft-types.ts | 42 ++ drafts/src/index.ts | 2 + drafts/src/providers/DraftListProvider.ts | 85 ++- drafts/src/styles.css | 26 +- 7 files changed, 1221 insertions(+), 397 deletions(-) create mode 100644 drafts/src/change-group-cache.ts diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index bed583f1..6bb65497 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -23,6 +23,8 @@ import { subscribeDoc, } from "@inkandswitch/patchwork-providers-solid"; import type { + CachedGroup, + ChangeGroupCacheDoc, CheckedOutDraft, CloneEntry, DraftCheckpoint, @@ -31,26 +33,32 @@ import type { DraftMemberDoc, HasDrafts, } from "./draft-types"; +import { + computeEditCounts, + ensureMainDraft, + getDocCreationTime, +} from "./change-group-cache"; // Seed for the read-only `draft:list` subscription until the provider answers. // `main.url` is a placeholder; the Main card displays the host doc url instead. const EMPTY_DRAFT_LIST: DraftList = { - main: { url: "" as AutomergeUrl, members: [], childCount: 0, name: null }, + main: { + url: "" as AutomergeUrl, + members: [], + childCount: 0, + name: null, + changeGroupCacheUrl: null, + }, drafts: [], }; // Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.20"; +const DRAFTS_VERSION = "0.0.23"; // Logged at module load so the console shows which build is running even // before the panel renders. console.log(`[drafts] DraftsSidebar v${DRAFTS_VERSION} loaded`); -// A pause between consecutive changes longer than this starts a new group: -// bursts of continuous editing read as a single row, however long they run, -// and any minute-plus lull splits the timeline. -const INACTIVITY_GAP = 60 * 1000; - export function DraftsSidebar(props: { element: HTMLElement }) { const [hostDoc, hostDocHandle] = subscribeDoc(props.element, { type: "draft:root-doc", @@ -178,34 +186,6 @@ export function DraftsSidebar(props: { element: HTMLElement }) { selectDraft(draft.url); }; - // Resolve the host doc's single main draft, creating it (and pointing - // `@patchwork.mainDraftUrl` at it) the first time. The main draft is - // bookkeeping only: the list provider seeds its identity `clones`, and its - // `drafts` holds the top-level draft list. - const ensureMainDraft = async ( - repo: Repo, - docHandle: DocHandle - ): Promise> => { - const existingUrl = docHandle.doc()?.["@patchwork"]?.mainDraftUrl; - if (existingUrl) return repo.find(existingUrl); - - const mainDraft = repo.create({ - "@patchwork": { type: "draft" }, - isMain: true, - parent: docHandle.url, - drafts: [], - clones: {}, - }); - docHandle.change((d) => { - // Mutate `@patchwork` in place. Spreading it into a fresh object and - // reassigning would carry over references to existing document objects, - // which Automerge rejects ("Cannot create a reference to an existing - // document object"). - d["@patchwork"]!.mainDraftUrl = mainDraft.url; - }); - return mainDraft; - }; - // Fork a new top-level draft off a historical version: every member doc is // cloned at the heads it had as of `head` (the dragged-out change), not at // the live latest. Pre-populating `DraftDoc.clones` here means the overlay's @@ -328,6 +308,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { hostDocUrl={hostDocHandle()?.url} isSelected={isMainSelected()} members={() => list().main.members} + changeGroupCacheUrl={list().main.changeGroupCacheUrl} name={list().main.name} onRename={(name) => void onRename(null, name)} onSelect={() => selectDraft(null)} @@ -346,6 +327,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { ; + changeGroupCacheUrl: AutomergeUrl | null; name: string | null; onRename: (name: string | null) => void; onSelect: () => void; @@ -780,6 +763,7 @@ function MainCard(props: { props.members} + changeGroupCacheUrl={props.changeGroupCacheUrl} mainDocUrl={props.mainDocUrl} onScrub={props.onScrub} scrubber={props.scrubber} @@ -894,38 +880,6 @@ function DraftName(props: { ); } -// One change in the interleaved timeline. `docUrl` is the original member url -// (used for labelling and as the checkpoint anchor), never the per-draft clone -// the change was read from. `title` is the source document's display title. -// `seq` is the change's per-document causal index, used only to break -// timestamp ties (see `collectInterleavedChanges`). `additions`/`deletions` -// are the change's rough edit magnitude, aggregated for the group +/- counts. -type DraftChange = { - docUrl: AutomergeUrl; - title: string; - hash: string; - // Automerge change time, in SECONDS (multiply by 1000 for a JS Date). - time: number; - actor: string; - message: string | null; - seq: number; - additions: number; - deletions: number; -}; - -// One burst of activity: consecutive changes separated by no more than -// INACTIVITY_GAP, regardless of author or document. Rendered as a single -// non-expandable row. Clicking it parks the scrubber at `newest`. -type TimeGroup = { - id: string; - endTime: number; - actors: string[]; - additions: number; - deletions: number; - newest: DraftChange; - changes: DraftChange[]; -}; - // A reference to one change in the interleaved timeline, by document and // hash. `time` steers how the *other* member docs' heads are resolved around // it (see `computeCheckpoint`). @@ -935,122 +889,214 @@ type ChangeRef = { time: number; }; -// Where the scrubber sits: the change whose heads the view displays. The -// head is anchored by change identity rather than index so a recomputed -// timeline doesn't move the token. +// Where the scrubber sits: the change whose heads the view displays, +// anchored to its cached group. `offset` is the change's position within the +// group, 0 = the group's newest change (what the scrubber geometry snaps +// to); `head` identifies the exact change for the checkpoint machinery. type ScrubberState = { + groupId: string; + offset: number; head: ChangeRef; }; -// Strip a timeline change down to the fields that identify it for scrubbing. -function changeRef(change: DraftChange): ChangeRef { - return { docUrl: change.docUrl, hash: change.hash, time: change.time }; -} +// One change recovered by the on-demand scrub-resolution scan. `doc` is the +// member doc it was read from (kept so the sticker can diff it on demand); +// `seq` is the change's per-document causal index, used only to break +// same-second timestamp ties — matching the fill engine's interleave order. +type ScanChange = { + docUrl: AutomergeUrl; + doc: Automerge.Doc; + hash: string; + time: number; + deps: string[]; + seq: number; +}; -// Renders a draft's (or main's) changes as a timeline of activity groups: -// every member doc's changes interleaved newest first, then split wherever -// the editing paused for INACTIVITY_GAP (see `groupChanges`). A gutter on +// Renders a draft's (or main's) timeline straight from its change-group +// cache doc: the provider's fill engine computes and persists the activity +// groups (newest first, older history backfilling), and this component is a +// pure reader — it paints from the cache before the member docs even load, +// and live edits arrive through the cache doc's change signal. A gutter on // the left spans the whole history (top = latest version, bottom = first); // the indicator — a calendar-style dot + line — marks the version being // looked at and paints *on top* of everything in the changes area. // Dragging starts only from its handles in the gutter; dragging it all the // way to the top returns to the latest version (`onReturnToLatest`). While // pinned, a sticker overlays the row at the head with the exact change the -// line sits on. The member set is passed in (from the card's `DraftSummary`); -// the effect below keeps the timeline live as those docs edit. +// line sits on. Individual changes are NOT cached: scrub positions resolve +// on demand by scanning the member docs' change metadata inside the group's +// time span (no diffs), memoized per group so dragging stays snappy. function DraftChangesList(props: { members: Accessor; + changeGroupCacheUrl: AutomergeUrl | null; mainDocUrl: AutomergeUrl | undefined; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; onDragVersion: (head: ChangeRef | null) => void; onReturnToLatest: () => void; }) { - const [changes, setChanges] = createSignal([]); + const repo = "repo" in window ? window.repo : undefined; + + // The cache doc, resolved from the summary's changeGroupCacheUrl and read + // live: the fill engine's background writes stream in as timeline rows. + const [cacheHandle, setCacheHandle] = + createSignal>(); + createEffect(() => { + const url = props.changeGroupCacheUrl; + setCacheHandle(undefined); + if (!url || !repo) return; + let disposed = false; + void repo.find(url).then( + (handle) => { + if (!disposed) setCacheHandle(handle); + }, + (err) => { + console.warn("[drafts] failed to load change-group cache:", url, err); + } + ); + onCleanup(() => { + disposed = true; + }); + }); + const cacheDoc = createDocSignal(cacheHandle); + + // The rendered rows: cached groups newest-first. Groups whose changes + // carry no edits (metadata-only churn — zero +/- counts) are stored in the + // cache (the fill engine needs the newest one to extend) but filtered here. + const timeGroups = createMemo(() => + Object.values(cacheDoc()?.groups ?? {}) + .filter((g) => g.additions > 0 || g.deletions > 0) + .sort((a, b) => b.endTime - a.endTime || (a.id < b.id ? -1 : 1)) + ); - // Whenever the member set changes, resolve a handle per member, listen for - // edits so the timeline stays live, and recompute. A `disposed` flag guards - // against the async resolution landing after the effect was torn down. + // Member doc handles (plus the creation-time cutoff), resolved once per + // member set — only needed to *scrub*, never to render the rows. + type MemberSource = { member: DraftMemberDoc; handle: DocHandle }; + const [sources, setSources] = createSignal(null); + const [createdAt, setCreatedAt] = createSignal( + undefined + ); createEffect(() => { const list = props.members(); const mainDocUrl = props.mainDocUrl; - const repo = "repo" in window ? window.repo : undefined; if (!repo) return; - let disposed = false; - const listeners: { handle: DocHandle; onChange: () => void }[] = - []; - - const recompute = async () => { - const next = await collectInterleavedChanges(repo, list, mainDocUrl); - if (!disposed) setChanges(next); - }; - + setSources(null); void (async () => { + const next: MemberSource[] = []; for (const member of list) { - const handle = await repo.find(member.cloneUrl ?? member.url); - if (disposed) return; - const onChange = () => void recompute(); - handle.on("change", onChange); - listeners.push({ handle, onChange }); + try { + const handle = await repo.find( + member.cloneUrl ?? member.url + ); + next.push({ member, handle }); + } catch (err) { + console.warn( + "[drafts] failed to resolve member for scrubbing:", + member, + err + ); + } } - void recompute(); + const created = await getDocCreationTime(repo, mainDocUrl); + if (disposed) return; + setCreatedAt(created); + setSources(next); })(); - onCleanup(() => { disposed = true; - for (const { handle, onChange } of listeners) { - handle.off("change", onChange); - } }); }); - // The flat, newest-first history folded into activity groups for rendering. - // Groups whose changes carry no edits (metadata-only churn — zero +/- - // counts) are dropped from the timeline entirely. - const timeGroups = createMemo(() => - groupChanges(changes()).filter((g) => g.additions > 0 || g.deletions > 0) - ); - - // The scrubbable timeline: the visible groups' changes flattened - // newest-first. All scrubber indices refer to this list, so it must stay - // consistent with the rendered rows (not the raw, unfiltered history). - const visibleChanges = createMemo(() => - timeGroups().flatMap((g) => g.changes) - ); - - // Scrub so the head sits at the timeline change at `headIndex` (global, - // 0 = newest). - const scrubTo = (headIndex: number) => { - const head = visibleChanges()[headIndex]; - if (!head) return; - props.onScrub({ head: changeRef(head) }); + // Recover a group's member changes on demand: scan each member's post-fork + // change metadata filtered to the group's span (spans are disjoint — groups + // are separated by >gap lulls, so time containment recovers exactly the + // group's changes) and interleave with the same ordering the fill engine + // uses. Metadata only, no diffs. Memoized per group identity so dragging + // stays snappy; returns null until the member handles resolve. Must apply + // the same filters as the fill (notably the pre-creation cutoff), or the + // scrubber's index math drifts from the cached changeCount. + const scanCache = new Map(); + const resolveGroupChanges = (group: CachedGroup): ScanChange[] | null => { + const key = `${group.id}:${group.changeCount}`; + const hit = scanCache.get(key); + if (hit) return hit; + const srcs = sources(); + if (!srcs) return null; + const cutoff = createdAt(); + const rows: ScanChange[] = []; + for (const { member, handle } of srcs) { + const doc = handle.doc() as Automerge.Doc | undefined; + if (!doc) continue; + try { + const since = member.clonedAt ? decodeHeads(member.clonedAt) : []; + const metas = Automerge.getChangesMetaSince(doc, since); + metas.forEach((meta, seq) => { + if (cutoff !== undefined && meta.time && meta.time < cutoff) return; + if (meta.time < group.startTime || meta.time > group.endTime) return; + rows.push({ + docUrl: member.url, + doc, + hash: meta.hash, + time: meta.time, + deps: meta.deps, + seq, + }); + }); + } catch (err) { + console.warn( + "[drafts] failed to scan changes for member:", + member, + err + ); + } + } + rows.sort((a, b) => b.time - a.time || b.seq - a.seq); + scanCache.set(key, rows); + return rows; }; - // Jump the scrubber to a group: head at the group's newest change. - const scrubToGroup = (group: TimeGroup) => { - props.onScrub({ head: changeRef(group.newest) }); + // Scrub so the head sits at `offset` within `group` (0 = the group's + // newest change, which the cache anchors directly; deeper offsets resolve + // through the on-demand scan). + const scrubTo = (group: CachedGroup, offset: number) => { + if (offset <= 0) { + props.onScrub({ + groupId: group.id, + offset: 0, + head: { + docUrl: group.newestMemberUrl, + hash: group.newestHash, + time: group.endTime, + }, + }); + return; + } + const rows = resolveGroupChanges(group); + if (!rows || rows.length === 0) return; + const row = rows[Math.min(offset, rows.length - 1)]; + props.onScrub({ + groupId: group.id, + offset, + head: { docUrl: row.docUrl, hash: row.hash, time: row.time }, + }); }; - // Where the scrubber head sits in the flat timeline; null when nothing is - // pinned (live latest) or the anchored change vanished from a recompute. - const headIndex = createMemo(() => { + // The group the scrubber head sits in: by group identity when it still + // exists, falling back to span containment (an extended group gets a new + // id, but its span still covers the pinned change). + const groupForScrub = (s: ScrubberState): CachedGroup | null => + timeGroups().find((g) => g.id === s.groupId) ?? + timeGroups().find( + (g) => s.head.time >= g.startTime && s.head.time <= g.endTime + ) ?? + null; + + const groupContainsHead = (group: CachedGroup): boolean => { const s = props.scrubber(); - if (!s) return null; - const idx = visibleChanges().findIndex( - (c) => c.hash === s.head.hash && c.docUrl === s.head.docUrl - ); - return idx >= 0 ? idx : null; - }); - - const groupContainsHead = (group: TimeGroup): boolean => { - const s = props.scrubber(); - return ( - !!s && - group.changes.some( - (c) => c.hash === s.head.hash && c.docUrl === s.head.docUrl - ) - ); + if (!s) return false; + if (s.groupId === group.id) return true; + return s.head.time >= group.startTime && s.head.time <= group.endTime; }; // --- Scrubber geometry --------------------------------------------------- @@ -1079,59 +1125,53 @@ function DraftChangesList(props: { }); type Band = { - startIndex: number; - count: number; + group: CachedGroup; top: number; height: number; }; const bands = createMemo(() => { measureTick(); const out: Band[] = []; - let index = 0; for (const group of timeGroups()) { const el = rowEls.get(group.id); if (el) { - out.push({ - startIndex: index, - count: group.changes.length, - top: el.offsetTop, - height: el.offsetHeight, - }); + out.push({ group, top: el.offsetTop, height: el.offsetHeight }); } - index += group.changes.length; } return out; }); - // Map a global change index to a y offset in the track. Indices interpolate - // across their group's band; an index past the oldest change maps to the - // very bottom. - const yForIndex = (index: number): number => { - const bs = bands(); - if (bs.length === 0) return 0; - for (const b of bs) { - if (index < b.startIndex + b.count) { - return b.top + ((index - b.startIndex) / b.count) * b.height; - } - } - const last = bs[bs.length - 1]; - return last.top + last.height; + // A scrub position's y in the track: offsets interpolate across their + // group's band, sized by the cached changeCount (the flat change list is + // never materialized). + const yForPosition = (band: Band, offset: number): number => { + const count = Math.max(1, band.group.changeCount); + return band.top + (Math.min(offset, count - 1) / count) * band.height; }; - // Inverse: the change index nearest a pointer y (in track coordinates). - const indexForY = (y: number): number => { + // Inverse: the (group, offset) position nearest a pointer y (in track + // coordinates). + const positionForY = ( + y: number + ): { group: CachedGroup; offset: number } | null => { const bs = bands(); - if (bs.length === 0) return 0; + if (bs.length === 0) return null; for (const b of bs) { - if (y < b.top) return b.startIndex; + if (y < b.top) return { group: b.group, offset: 0 }; if (y < b.top + b.height) { - const idx = - b.startIndex + Math.round(((y - b.top) / b.height) * b.count); - return Math.min(idx, b.startIndex + b.count - 1); + const count = Math.max(1, b.group.changeCount); + const offset = Math.min( + Math.round(((y - b.top) / b.height) * count), + count - 1 + ); + return { group: b.group, offset }; } } const last = bs[bs.length - 1]; - return Math.max(0, last.startIndex + last.count - 1); + return { + group: last.group, + offset: Math.max(0, last.group.changeCount - 1), + }; }; // The indicator's pixel position: the head line's y in the track. The @@ -1139,8 +1179,14 @@ function DraftChangesList(props: { // grabbable. With nothing pinned it idles at the very top — you're looking // at the live latest. const tokenGeometry = createMemo(() => { - if (visibleChanges().length === 0 || bands().length === 0) return null; - return { top: yForIndex(headIndex() ?? 0) }; + const bs = bands(); + if (bs.length === 0) return null; + const s = props.scrubber(); + if (!s) return { top: bs[0].top }; + const group = groupForScrub(s); + const band = group ? bs.find((b) => b.group.id === group.id) : undefined; + if (!band) return { top: bs[0].top }; + return { top: yForPosition(band, s.offset) }; }); let trackEl: HTMLDivElement | undefined; @@ -1160,18 +1206,25 @@ function DraftChangesList(props: { // all the way to the top (the newest change) means "return to the latest // version": it drops the pin rather than freezing at the newest change. const beginDrag = (ev: PointerEvent) => { - if (!trackEl || visibleChanges().length === 0) return; + if (!trackEl || bands().length === 0) return; ev.preventDefault(); ev.stopPropagation(); - const grabOffset = yInTrack(ev) - yForIndex(headIndex() ?? 0); + const grabOffset = yInTrack(ev) - (tokenGeometry()?.top ?? 0); - let last = headIndex() ?? 0; + const s = props.scrubber(); + let last = s ? `${s.groupId}:${s.offset}` : null; const onMove = (e: PointerEvent) => { - const head = indexForY(yInTrack(e) - grabOffset); - if (head === last) return; - last = head; - if (head === 0) props.onReturnToLatest(); - else scrubTo(head); + const pos = positionForY(yInTrack(e) - grabOffset); + if (!pos) return; + const key = `${pos.group.id}:${pos.offset}`; + if (key === last) return; + last = key; + const first = bands()[0]; + if (first && pos.group.id === first.group.id && pos.offset === 0) { + props.onReturnToLatest(); + } else { + scrubTo(pos.group, pos.offset); + } }; const target = ev.currentTarget as HTMLElement; @@ -1196,20 +1249,50 @@ function DraftChangesList(props: { props.onDragVersion(head); }; - // The exact change the scrubber head sits on; feeds the sticker that - // overlays the group row with the version being looked at. It is - // suppressed when the head sits exactly on a group's newest change (the - // row already shows that version). - const headChange = createMemo(() => { - const idx = headIndex(); - const change = idx === null ? null : (visibleChanges()[idx] ?? null); - if (!change) return null; - const atGroupStart = timeGroups().some( - (g) => g.newest.hash === change.hash && g.newest.docUrl === change.docUrl + // The exact change the scrubber head sits on, recovered through the + // on-demand scan; feeds the sticker that overlays the group row with the + // version being looked at. It is suppressed when the head sits exactly on + // a group's newest change (the row already shows that version). + const headChange = createMemo(() => { + const s = props.scrubber(); + if (!s || s.offset === 0) return null; + const group = groupForScrub(s); + if (!group || s.head.hash === group.newestHash) return null; + const rows = resolveGroupChanges(group); + if (!rows || rows.length === 0) return null; + return ( + rows.find( + (r) => r.hash === s.head.hash && r.docUrl === s.head.docUrl + ) ?? + rows[Math.min(s.offset, rows.length - 1)] ?? + null ); - return atGroupStart ? null : change; }); + // The sticker's per-change +/- counts: one on-demand diff of the single + // change under the head (the cache stores only group aggregates). + const headCounts = createMemo(() => { + const change = headChange(); + if (!change) return null; + return computeEditCounts(change.doc, change.hash, change.deps); + }); + + // The sticker's title, resolved lazily per member doc and cached. + const [titles, setTitles] = createSignal>({}); + createEffect(() => { + const change = headChange(); + if (!change) return; + if (titles()[change.docUrl] !== undefined) return; + void resolveDocTitle(change.doc, change.docUrl).then((title) => { + setTitles((t) => ({ ...t, [change.docUrl]: title })); + }); + }); + const headTitle = (): string => { + const change = headChange(); + if (!change) return ""; + return titles()[change.docUrl] ?? shortUrl(change.docUrl); + }; + return (
rowEls.set(group.id, el)} isSelected={groupContainsHead(group)} - onSelect={() => scrubToGroup(group)} + onSelect={() => scrubTo(group, 0)} onVersionDragStart={(e) => - beginVersionDrag(e, changeRef(group.newest)) + beginVersionDrag(e, { + docUrl: group.newestMemberUrl, + hash: group.newestHash, + time: group.endTime, + }) } onVersionDragEnd={() => props.onDragVersion(null)} /> @@ -1262,18 +1349,22 @@ function DraftChangesList(props: { draggable={true} title="Drag out to fork a new draft from this version" onDragStart={(e) => - beginVersionDrag(e, changeRef(change())) + beginVersionDrag(e, { + docUrl: change().docUrl, + hash: change().hash, + time: change().time, + }) } onDragEnd={() => props.onDragVersion(null)} > {formatTime(change().time)} - {change().title} + {headTitle()}
)} @@ -1294,7 +1385,7 @@ function DraftChangesList(props: { // dragstart only fires past the movement threshold, so click-to-select is // unaffected. function TimeGroupRow(props: { - group: TimeGroup; + group: CachedGroup; rowRef: (el: HTMLElement) => void; isSelected: boolean; onSelect: () => void; @@ -1366,58 +1457,6 @@ function EditCounts(props: { additions: number; deletions: number }) { ); } -// Fold a flat, newest-first list of changes into activity groups. Consecutive -// changes stay in the same group while the pause between them is at most -// INACTIVITY_GAP; a longer lull starts a new group. A group can span any -// stretch of continuous editing — only inactivity splits it. -function groupChanges(changes: DraftChange[]): TimeGroup[] { - const timeGroups: TimeGroup[] = []; - let window: DraftChange[] = []; - let prevTimeMs: number | null = null; - - const flush = () => { - if (window.length > 0) { - timeGroups.push(buildTimeGroup(window)); - window = []; - } - }; - - for (const change of changes) { - const timeMs = change.time * 1000; - // Rows arrive newest-first, so the previous row is this change's newer - // neighbour; a gap larger than the threshold between them is a lull. - if (prevTimeMs !== null && prevTimeMs - timeMs > INACTIVITY_GAP) flush(); - window.push(change); - prevTimeMs = timeMs; - } - flush(); - - return timeGroups; -} - -// Build one group from a window of newest-first changes: dedupe the authors -// (newest contributor first) and aggregate the +/- counts. -function buildTimeGroup(windowNewestFirst: DraftChange[]): TimeGroup { - const actors: string[] = []; - let additions = 0; - let deletions = 0; - for (const c of windowNewestFirst) { - if (!actors.includes(c.actor)) actors.push(c.actor); - additions += c.additions; - deletions += c.deletions; - } - const newest = windowNewestFirst[0]; - return { - id: `tg-${newest.hash}`, - endTime: newest.time, - actors, - additions, - deletions, - newest, - changes: windowNewestFirst, - }; -} - // Build the checkpoint map for a scrub position. Each member's displayed // version (`to`) is its heads as of `head`: the doc that owns that change is // pinned exactly to it, every other member to its latest change at or before @@ -1472,134 +1511,6 @@ async function computeCheckpoint( return checkpoint; } -// Collect every member doc's post-fork changes into one interleaved timeline, -// newest first. `getChangesMetaSince` returns each doc's changes in topological -// (causal, oldest-first) order, so we stamp each change with its per-document -// `seq` index and sort by time with `seq` as the tie-break: `meta.time` is only -// second-resolution, so changes sharing a timestamp fall back to their -// document's own change order rather than being shuffled. On a draft `clonedAt` -// is set, so reading the clone since that fork point yields exactly the draft's -// own changes; on main both clone fields are null, so we read the original doc -// since `[]` for its full history. Members with no changes are omitted. -// -// Changes that predate the root document's creation are dropped: a member doc -// dragged in after the fact (e.g. a tldraw with its own prior edit history) -// would otherwise contribute changes from before this document even existed, -// which reads as noise. When the cutoff can't be resolved we keep everything. -async function collectInterleavedChanges( - repo: Repo, - members: DraftMemberDoc[], - mainDocUrl: AutomergeUrl | undefined -): Promise { - const rows: DraftChange[] = []; - const createdAt = await getDocCreationTime(repo, mainDocUrl); - for (const member of members) { - try { - const handle = await repo.find(member.cloneUrl ?? member.url); - const doc = handle.doc(); - if (!doc) continue; - const since = member.clonedAt ? decodeHeads(member.clonedAt) : []; - const metas = Automerge.getChangesMetaSince(doc, since); - if (metas.length === 0) continue; - const title = await resolveDocTitle(doc, member.url); - metas.forEach((meta, seq) => { - // Hide anything from before the root document was created. `seq` still - // reflects the change's true per-document position, so dropping rows - // here doesn't disturb the tie-break ordering. - if (createdAt !== undefined && meta.time && meta.time < createdAt) { - return; - } - const { additions, deletions } = computeEditCounts( - doc as Automerge.Doc, - meta.hash, - meta.deps - ); - rows.push({ - docUrl: member.url, - title, - hash: meta.hash, - time: meta.time, - actor: meta.actor, - message: meta.message, - seq, - additions, - deletions, - }); - }); - } catch (err) { - // A member doc that can't be resolved (or whose fork point is missing) - // is simply omitted rather than failing the whole list. - console.warn("[drafts] failed to read changes for member:", member, err); - } - } - // Newest first by timestamp. On a tie (meta.time is only second-resolution), - // fall back to each document's own change order, also newest-first: `seq` is - // the per-document causal index (oldest = 0), so the higher (later) seq sorts - // first. This keeps same-second changes consistent with the newest-first - // intent instead of flipping that run to oldest-first. - rows.sort((a, b) => b.time - a.time || b.seq - a.seq); - return rows; -} - -// Work out one change's rough edit magnitude by diffing it against its parents -// and counting its patches: splice lengths and insert counts as additions, del -// lengths as deletions, everything else (put / inc / mark / …) as one addition. -// `@patchwork` metadata paths are ignored. Feeds the group +/- counts. -function computeEditCounts( - doc: Automerge.Doc, - hash: string, - deps: string[] -): { additions: number; deletions: number } { - let additions = 0; - let deletions = 0; - try { - const patches = Automerge.diff( - doc, - deps as unknown as Automerge.Heads, - [hash] as unknown as Automerge.Heads - ); - for (const patch of patches) { - if (patch.path[0] === "@patchwork") continue; - if (patch.action === "splice") { - additions += (patch.value as string).length; - } else if (patch.action === "insert") { - additions += Array.isArray((patch as { values?: unknown[] }).values) - ? (patch as { values: unknown[] }).values.length - : 1; - } else if (patch.action === "del") { - deletions += (patch as { length?: number }).length ?? 1; - } else { - additions += 1; - } - } - } catch (err) { - console.warn("[drafts] failed to diff change for edit counts:", hash, err); - } - return { additions, deletions }; -} - -// When was a document created, as a Unix SECONDS timestamp? Reads the doc's -// 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. -async function getDocCreationTime( - repo: Repo, - url: AutomergeUrl | undefined -): Promise { - if (!url) return undefined; - 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; - } catch (err) { - console.warn("[drafts] failed to resolve creation time for:", url, err); - return undefined; - } -} - // Resolve a document's display title: prefer its cached `@patchwork.title`, // otherwise ask its datatype for one, falling back to a short url. Mirrors the // sideboard's `docLinkFromUrl` but reuses an already-loaded doc. diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts new file mode 100644 index 00000000..8ed14878 --- /dev/null +++ b/drafts/src/change-group-cache.ts @@ -0,0 +1,785 @@ +import { + decodeHeads, + encodeHeads, + isValidAutomergeUrl, + type AutomergeUrl, + type DocHandle, + type Repo, + type UrlHeads, +} from "@automerge/automerge-repo"; +import * as Automerge from "@automerge/automerge"; + +import type { + CachedGroup, + ChangeGroupCacheDoc, + DraftDoc, + DraftMemberDoc, + HasDrafts, +} from "./draft-types.js"; + +// Bump to discard every existing cache doc's contents (they self-rebuild). +export const CHANGE_GROUP_CACHE_VERSION = 1; + +// A pause between consecutive changes longer than this starts a new group: +// bursts of continuous editing read as a single row, however long they run, +// and any minute-plus lull splits the timeline. Baked into the cache doc — +// changing it invalidates and rebuilds every cache. +export const INACTIVITY_GAP_MS = 60 * 1000; + +// How long a fill may hold the main thread before yielding to idle time. +const SLICE_BUDGET_MS = 8; + +// Coalesce bursts of member-doc change events into one incremental fill. +const FILL_DEBOUNCE_MS = 250; + +// One timeline the filler 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 +// the "before this document existed" cutoff. +export type TimelineSpec = { + draftHandle: DocHandle; + members: DraftMemberDoc[]; + rootDocUrl: AutomergeUrl; +}; + +export type ChangeGroupCacheFiller = { + // Reconcile the set of timelines to keep filled (ordered by priority: + // earlier specs fill first). Attaches member-doc change listeners that + // drive incremental fills; timelines absent from the list are torn down. + sync: (specs: TimelineSpec[]) => void; + dispose: () => void; +}; + +// Resolve the host doc's single main draft, creating it (and pointing +// `@patchwork.mainDraftUrl` at it) the first time. The main draft is +// bookkeeping only: the list provider seeds its identity `clones`, and its +// `drafts` holds the top-level draft list. Check-then-create — the rare +// concurrent-create orphan is accepted, same as always. +export async function ensureMainDraft( + repo: Repo, + docHandle: DocHandle +): Promise> { + const existingUrl = docHandle.doc()?.["@patchwork"]?.mainDraftUrl; + if (existingUrl) return repo.find(existingUrl); + + const mainDraft = repo.create({ + "@patchwork": { type: "draft" }, + isMain: true, + parent: docHandle.url, + drafts: [], + clones: {}, + }); + docHandle.change((d) => { + // Mutate `@patchwork` in place. Spreading it into a fresh object and + // reassigning would carry over references to existing document objects, + // which Automerge rejects ("Cannot create a reference to an existing + // document object"). + if (!d["@patchwork"]) d["@patchwork"] = {}; + d["@patchwork"]!.mainDraftUrl = mainDraft.url; + }); + return mainDraft; +} + +// Resolve a draft's change-group cache doc, creating it and stamping +// `changeGroupCacheUrl` the first time. A cache whose format version or +// grouping parameter no longer matches is emptied in place (same url) and +// left to rebuild. +export async function ensureChangeGroupCache( + repo: Repo, + draftHandle: DocHandle +): Promise> { + const existingUrl = draftHandle.doc()?.changeGroupCacheUrl; + if (existingUrl && isValidAutomergeUrl(existingUrl)) { + const handle = await repo.find(existingUrl); + const doc = handle.doc(); + if ( + doc && + (doc.version !== CHANGE_GROUP_CACHE_VERSION || + doc.inactivityGapMs !== INACTIVITY_GAP_MS) + ) { + handle.change((d) => { + d.version = CHANGE_GROUP_CACHE_VERSION; + d.inactivityGapMs = INACTIVITY_GAP_MS; + d.groups = {}; + d.computedThrough = {}; + }); + } + return handle; + } + + const cache = repo.create({ + "@patchwork": { type: "change-group-cache" }, + version: CHANGE_GROUP_CACHE_VERSION, + inactivityGapMs: INACTIVITY_GAP_MS, + groups: {}, + computedThrough: {}, + }); + draftHandle.change((d) => { + if (!d.changeGroupCacheUrl) d.changeGroupCacheUrl = cache.url; + }); + // A concurrent creator may have won the stamp; honor whichever pointer + // settled (our fresh doc is then an accepted orphan, same as mainDraftUrl). + const settled = draftHandle.doc()?.changeGroupCacheUrl; + if (settled && settled !== cache.url && isValidAutomergeUrl(settled)) { + return repo.find(settled); + } + return cache; +} + +// Work out one change's rough edit magnitude by diffing it against its parents +// and counting its patches: splice lengths and insert counts as additions, del +// lengths as deletions, everything else (put / inc / mark / …) as one addition. +// `@patchwork` metadata paths are ignored. A pure function of immutable +// history, so each change is diffed once, here, and only the group aggregates +// are kept. +export function computeEditCounts( + doc: Automerge.Doc, + hash: string, + deps: string[] +): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + try { + const patches = Automerge.diff( + doc, + deps as unknown as Automerge.Heads, + [hash] as unknown as Automerge.Heads + ); + for (const patch of patches) { + if (patch.path[0] === "@patchwork") continue; + if (patch.action === "splice") { + additions += (patch.value as string).length; + } else if (patch.action === "insert") { + additions += Array.isArray((patch as { values?: unknown[] }).values) + ? (patch as { values: unknown[] }).values.length + : 1; + } else if (patch.action === "del") { + deletions += (patch as { length?: number }).length ?? 1; + } else { + additions += 1; + } + } + } catch (err) { + console.warn("[drafts] failed to diff change for edit counts:", hash, err); + } + return { additions, deletions }; +} + +// When was a document created, as a Unix SECONDS timestamp? Reads the doc's +// 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. +export async function getDocCreationTime( + repo: Repo, + url: AutomergeUrl | undefined +): Promise { + if (!url) return undefined; + 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; + } 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 = { + memberUrl: AutomergeUrl; + doc: Automerge.Doc; + hash: string; + deps: string[]; + time: number; + actor: string; + seq: number; +}; + +// Newest first by timestamp, per-doc causal order breaking same-second ties. +// The sidebar's on-demand scrub resolution MUST order identically, or the +// scrubber's index math drifts from `changeCount`. +function newestFirst(a: PendingChange, b: PendingChange): number { + return b.time - a.time || b.seq - a.seq; +} + +// Fold a flat, newest-first list of changes into groups: consecutive changes +// stay together while the pause between them is at most the inactivity gap. +function splitIntoGroups( + rowsNewestFirst: PendingChange[] +): PendingChange[][] { + const groups: PendingChange[][] = []; + let window: PendingChange[] = []; + let prevTimeMs: number | null = null; + for (const row of rowsNewestFirst) { + const timeMs = row.time * 1000; + // Rows arrive newest-first, so the previous row is this change's newer + // neighbour; a gap larger than the threshold between them is a lull. + if ( + prevTimeMs !== null && + prevTimeMs - timeMs > INACTIVITY_GAP_MS && + window.length > 0 + ) { + groups.push(window); + window = []; + } + window.push(row); + prevTimeMs = timeMs; + } + if (window.length > 0) groups.push(window); + return groups; +} + +function groupId(rowsNewestFirst: PendingChange[]): string { + return `tg-${rowsNewestFirst[0].hash}`; +} + +// Append `member`'s changes since `since` onto `out`, dropping anything from +// 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( + out: PendingChange[], + member: DraftMemberDoc, + doc: Automerge.Doc, + since: Automerge.Heads, + createdAt: number | undefined +): void { + let metas; + try { + metas = Automerge.getChangesMetaSince(doc, since); + } catch (err) { + console.warn( + "[drafts] change-group cache: failed to read changes for member:", + member.url, + err + ); + return; + } + 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, + }); + }); +} + +function dedupedActors(rowsNewestFirst: PendingChange[]): string[] { + const actors: string[] = []; + for (const row of rowsNewestFirst) { + if (!actors.includes(row.actor)) actors.push(row.actor); + } + return actors; +} + +// Yield to the main thread between diff slices. +function idle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => resolve()); + } else { + setTimeout(resolve, 0); + } + }); +} + +// 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. +type Slicer = { tick: () => Promise }; + +function createSlicer(isAborted: () => boolean, onYield: () => void): 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(); + }, + }; +} + +// Diff every change in a group (newest-first, sliced) and aggregate the +// result down to the CachedGroup a timeline row renders. Returns null when +// the run was aborted mid-diff. +async function buildGroup( + rowsNewestFirst: PendingChange[], + slicer: Slicer +): Promise { + 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; + } + const newest = rowsNewestFirst[0]; + const oldest = rowsNewestFirst[rowsNewestFirst.length - 1]; + return { + id: groupId(rowsNewestFirst), + startTime: oldest.time, + endTime: newest.time, + newestMemberUrl: newest.memberUrl, + newestHash: newest.hash, + actors: dedupedActors(rowsNewestFirst), + additions, + deletions, + changeCount: rowsNewestFirst.length, + }; +} + +function byMemberUrl(a: DraftMemberDoc, b: DraftMemberDoc): number { + return a.url < b.url ? -1 : a.url > b.url ? 1 : 0; +} + +function sameHeads(a: UrlHeads | undefined, b: UrlHeads | undefined): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + const set = new Set(b); + return a.every((h) => set.has(h)); +} + +// The write side of the grouping cache: owns one background task per +// timeline, listens to member docs for edits, and fills each timeline's +// ChangeGroupCacheDoc in idle-time slices — newest groups first, older +// history backfilling behind them. One global runner processes timelines +// sequentially in the priority order `sync` was given. +export function createChangeGroupCacheFiller( + repo: Repo +): ChangeGroupCacheFiller { + // 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: () => void; + }; + + type Task = { + key: AutomergeUrl; // the DraftDoc url + spec: TimelineSpec; + listeners: Map; + queued: boolean; + debounce: ReturnType | null; + }; + + const tasks = new Map(); + const queue: AutomergeUrl[] = []; + 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 sync(specs: TimelineSpec[]): void { + if (disposed) return; + const keep = new Set(specs.map((s) => s.draftHandle.url)); + for (const [key, task] of [...tasks]) { + if (!keep.has(key)) removeTask(task); + } + for (const spec of specs) { + const key = spec.draftHandle.url; + let task = tasks.get(key); + const membersChanged = + !task || !sameMemberSets(task.spec.members, spec.members); + if (!task) { + task = { key, spec, listeners: new Map(), queued: false, debounce: null }; + tasks.set(key, task); + } else { + task.spec = spec; + } + void ensureListeners(task); + if (membersChanged) schedule(key); + } + } + + function dispose(): void { + disposed = true; + for (const [, task] of [...tasks]) removeTask(task); + queue.length = 0; + } + + return { sync, dispose }; + + function removeTask(task: Task): void { + if (task.debounce) clearTimeout(task.debounce); + task.debounce = null; + for (const [, { handle, onChange }] of task.listeners) { + handle?.off("change", onChange); + } + task.listeners.clear(); + // A queued entry is skipped by the pump once the task is gone. + tasks.delete(task.key); + } + + function sameMemberSets(a: DraftMemberDoc[], b: DraftMemberDoc[]): boolean { + if (a.length !== b.length) return false; + const key = (m: DraftMemberDoc) => `${m.url}:${m.cloneUrl ?? ""}`; + const set = new Set(a.map(key)); + return b.every((m) => set.has(key(m))); + } + + // Keep exactly one change listener per member source doc; edits schedule a + // debounced incremental fill of the owning timeline. + async function ensureListeners(task: Task): Promise { + const wanted = new Set( + task.spec.members.map((m) => m.cloneUrl ?? m.url) + ); + for (const [url, { handle, onChange }] of [...task.listeners]) { + if (!wanted.has(url)) { + handle?.off("change", onChange); + task.listeners.delete(url); + } + } + for (const url of wanted) { + if (task.listeners.has(url)) continue; + const onChange = () => { + if (task.debounce) clearTimeout(task.debounce); + task.debounce = setTimeout(() => { + task.debounce = null; + schedule(task.key); + }, FILL_DEBOUNCE_MS); + }; + const slot: SourceListener = { handle: null, onChange }; + task.listeners.set(url, slot); + try { + const handle = await repo.find(url); + // The task was torn down or the slot dropped while resolving. + if (disposed || tasks.get(task.key) !== task) return; + if (task.listeners.get(url) !== slot) return; + handle.on("change", onChange); + slot.handle = handle; + } catch (err) { + if (task.listeners.get(url) === slot) task.listeners.delete(url); + console.warn( + "[drafts] change-group cache: failed to watch member:", + url, + err + ); + } + } + } + + function schedule(key: AutomergeUrl): void { + if (disposed) return; + const task = tasks.get(key); + if (!task || task.queued) return; + task.queued = true; + queue.push(key); + void pump(); + } + + async function pump(): Promise { + if (running || disposed) return; + running = true; + try { + while (queue.length > 0 && !disposed) { + const key = queue.shift()!; + const task = tasks.get(key); + if (!task) continue; + task.queued = false; + try { + await fillTimeline(task); + } catch (err) { + console.warn( + "[drafts] change-group cache fill failed for:", + key, + err + ); + } + } + } finally { + running = false; + } + } + + // One fill pass over a timeline. The common case appends the unconsumed + // tail onto the newest group; anything that would reshape older groups + // (late-syncing changes, a fresh cache) falls back to a full rebuild that + // reuses stored groups wherever they come out identical. + async function fillTimeline(task: Task): Promise { + const spec = task.spec; + // The run is stale once the task was replaced or torn down (a newer spec + // re-queues itself), or the filler disposed. + const isAborted = () => disposed || tasks.get(task.key) !== task; + + if (!spec.draftHandle.doc()) return; + const cacheHandle = await ensureChangeGroupCache(repo, spec.draftHandle); + const createdAt = await creationTime(spec.rootDocUrl); + if (isAborted()) return; + + // Resolve member sources, sorted by member url so cross-doc timestamp + // ties interleave identically on every client. + const members = [...spec.members].sort(byMemberUrl); + const sources: { member: DraftMemberDoc; doc: Automerge.Doc }[] = + []; + for (const member of members) { + try { + const handle = await repo.find(member.cloneUrl ?? member.url); + const doc = handle.doc(); + if (doc) sources.push({ member, doc: doc as Automerge.Doc }); + } catch (err) { + console.warn( + "[drafts] change-group cache: failed to resolve member:", + member, + err + ); + } + } + if (isAborted()) return; + + const cacheDoc = cacheHandle.doc(); + if (!cacheDoc) return; + const computedThrough = cacheDoc.computedThrough ?? {}; + + // 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 frontier: Record = {}; + const tails: PendingChange[] = []; + for (const { member, doc } of sources) { + frontier[member.url] = encodeHeads(Automerge.getHeads(doc)); + const consumed = computedThrough[member.url]; + const since = consumed + ? decodeHeads(consumed) + : member.clonedAt + ? decodeHeads(member.clonedAt) + : []; + collectMemberRows(tails, member, doc, since, createdAt); + } + + if (tails.length === 0) { + // Nothing new to group; just record any frontier movement (e.g. members + // whose unconsumed changes were all filtered out, or brand-new members + // with no post-fork changes yet). + const stale = Object.entries(frontier).filter( + ([url, heads]) => !sameHeads(computedThrough[url as AutomergeUrl], heads) + ); + if (stale.length > 0) { + cacheHandle.change((d) => { + for (const [url, heads] of stale) { + d.computedThrough[url as AutomergeUrl] = heads; + } + }); + } + return; + } + + tails.sort(newestFirst); + + // Fast path: every new change lands on or after the newest stored group + // (extending it or opening newer ones) without bridging into the group + // below it — the overwhelmingly common live-editing case. Everything else + // (first build, members without a consumed marker, late-syncing changes + // with old timestamps) rebuilds via the full pass. + const stored = Object.values(cacheDoc.groups ?? {}).sort( + (a, b) => b.endTime - a.endTime + ); + const newestStored = stored[0]; + const secondStored = stored[1]; + const tailOldestMs = tails[tails.length - 1].time * 1000; + const fastOk = + !!newestStored && + tailOldestMs >= newestStored.startTime * 1000 - INACTIVITY_GAP_MS && + (!secondStored || + tailOldestMs - secondStored.endTime * 1000 > INACTIVITY_GAP_MS); + + if (fastOk) { + await appendTail(cacheHandle, newestStored, tails, frontier, isAborted); + } else { + await rebuildAll(cacheHandle, sources, createdAt, frontier, isAborted); + } + } + + // 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( + cacheHandle: DocHandle, + newestStored: CachedGroup, + tailsNewestFirst: PendingChange[], + frontier: Record, + isAborted: () => boolean + ): Promise { + const tailGroups = splitIntoGroups(tailsNewestFirst); + const oldestGroup = tailGroups[tailGroups.length - 1]; + const oldestGroupOldestMs = + oldestGroup[oldestGroup.length - 1].time * 1000; + // The oldest run of new changes merges into the stored group when no lull + // separates them (it may even start inside the stored span). + const attaches = + oldestGroupOldestMs <= newestStored.endTime * 1000 + INACTIVITY_GAP_MS; + const freshGroups = attaches ? tailGroups.slice(0, -1) : tailGroups; + + const slicer = createSlicer(isAborted, () => {}); + const built: CachedGroup[] = []; + 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; + cacheHandle.change((d) => { + for (const group of built) d.groups[group.id] = group; + if (attaches && extensionSums) { + extendGroup(d, newestStored, oldestGroup, extensionSums); + } + for (const [url, heads] of Object.entries(frontier)) { + 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. + function extendGroup( + d: ChangeGroupCacheDoc, + storedSnapshot: CachedGroup, + tailNewestFirst: PendingChange[], + sums: { additions: number; deletions: number } + ): void { + const tailNewest = tailNewestFirst[0]; + const tailOldest = tailNewestFirst[tailNewestFirst.length - 1]; + const base = d.groups[storedSnapshot.id]; + if (!base) { + // The stored group vanished under us (concurrent rewrite); keep the + // tail as its own group rather than losing it — the next full pass + // reconciles the shape. + d.groups[`tg-${tailNewest.hash}`] = { + id: `tg-${tailNewest.hash}`, + startTime: tailOldest.time, + endTime: tailNewest.time, + newestMemberUrl: tailNewest.memberUrl, + newestHash: tailNewest.hash, + actors: dedupedActors(tailNewestFirst), + additions: sums.additions, + deletions: sums.deletions, + changeCount: tailNewestFirst.length, + }; + return; + } + + // The tail is newer causally, but a merge can deliver changes stamped + // inside the stored span; only a strictly later timestamp moves the + // anchor. + const newer = tailNewest.time > base.endTime; + const tailActors = dedupedActors(tailNewestFirst); + const baseActors = [...base.actors]; + const actors = newer + ? [...tailActors, ...baseActors.filter((a) => !tailActors.includes(a))] + : [...baseActors, ...tailActors.filter((a) => !baseActors.includes(a))]; + + const extended: CachedGroup = { + id: newer ? `tg-${tailNewest.hash}` : base.id, + startTime: Math.min(base.startTime, tailOldest.time), + endTime: Math.max(base.endTime, tailNewest.time), + newestMemberUrl: newer ? tailNewest.memberUrl : base.newestMemberUrl, + newestHash: newer ? tailNewest.hash : base.newestHash, + actors, + additions: base.additions + sums.additions, + deletions: base.deletions + sums.deletions, + changeCount: base.changeCount + tailNewestFirst.length, + }; + if (extended.id !== storedSnapshot.id) delete d.groups[storedSnapshot.id]; + 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 fill syncs in). Stale ids and the consumed markers settle in the + // final write. + async function rebuildAll( + cacheHandle: DocHandle, + sources: { member: DraftMemberDoc; doc: Automerge.Doc }[], + createdAt: number | undefined, + frontier: Record, + isAborted: () => boolean + ): Promise { + const rows: PendingChange[] = []; + for (const { member, doc } of sources) { + const since = member.clonedAt ? decodeHeads(member.clonedAt) : []; + collectMemberRows(rows, member, doc, since, createdAt); + } + rows.sort(newestFirst); + const groupsRows = splitIntoGroups(rows); + const expectedIds = new Set(groupsRows.map(groupId)); + + const batch: CachedGroup[] = []; + const flush = () => { + if (batch.length === 0) return; + cacheHandle.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 = cacheHandle.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); + } + + if (isAborted()) return; + cacheHandle.change((d) => { + for (const group of batch) 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; + } + }); + } +} diff --git a/drafts/src/clone-policy.ts b/drafts/src/clone-policy.ts index 548f395c..be708d96 100644 --- a/drafts/src/clone-policy.ts +++ b/drafts/src/clone-policy.ts @@ -26,6 +26,7 @@ export const SKIPPED_DATATYPES: ReadonlySet = new Set([ "account", "contact", "draft", + "change-group-cache", ]); // Reduce a url to its bare document identity by stripping any path/heads diff --git a/drafts/src/draft-types.ts b/drafts/src/draft-types.ts index 23023204..84b44c5b 100644 --- a/drafts/src/draft-types.ts +++ b/drafts/src/draft-types.ts @@ -34,6 +34,44 @@ export type DraftDoc = { drafts: AutomergeUrl[]; clones: Record; mergedAt?: number; + // Points at this draft's ChangeGroupCacheDoc, holding the precomputed + // activity groups for its timeline. Stamped lazily by the fill engine the + // first time it touches the timeline (see change-group-cache.ts). + changeGroupCacheUrl?: AutomergeUrl; +}; + +// One cached burst of activity in a draft's timeline: consecutive changes +// (interleaved across the draft's member docs) separated by no more than the +// inactivity gap, aggregated down to what a timeline row renders. Computed +// once per change by the fill engine and persisted, so the sidebar never has +// to re-diff history. +export type CachedGroup = { + id: string; // `tg-${newestHash}` — stable group identity + startTime: number; // span covered, seconds (automerge change time) + endTime: number; + // Anchor change (click-to-pin, drag-to-fork): which MEMBER doc owns the + // group's newest change, and its hash. Varies per group — a burst's last + // edit can land in any member (host doc or embedded doc). + newestMemberUrl: AutomergeUrl; + newestHash: string; + actors: string[]; // deduped authors, newest contributor first + additions: number; // summed across ALL member docs in the span + deletions: number; + changeCount: number; // for scrubber band geometry +}; + +// Self-contained: one cache doc per DraftDoc, holding that draft's timeline. +export type ChangeGroupCacheDoc = { + "@patchwork": { type: "change-group-cache" }; + version: number; // cache format; mismatch = rebuild + inactivityGapMs: number; // grouping param baked in; changed = rebuild + // Keyed by group id, ordered on read by endTime desc. A map (not array) so + // concurrent writers converge per-group instead of duplicating rows. + groups: Record; + // Per member: heads the grouping has consumed. getChangesMetaSince(doc, + // these) yields exactly the unconsumed tail — including late-syncing + // changes with old timestamps, which is what makes invalidation detectable. + computedThrough: Record; }; // One member doc's pinned view within a checkpoint. `to` is the heads to render @@ -109,6 +147,10 @@ export type DraftSummary = { // User-given display name (`DraftDoc.name`); `null` (not optional, to stay // structured-cloneable) means unnamed — the card shows its default label. name: string | null; + // The draft's ChangeGroupCacheDoc url (`DraftDoc.changeGroupCacheUrl`); + // `null` until the fill engine stamps it. Cards read their timeline's + // cached groups straight from this doc. + changeGroupCacheUrl: AutomergeUrl | null; }; // Response shape for `draft:list`: the host doc's `main` entry plus the flat, diff --git a/drafts/src/index.ts b/drafts/src/index.ts index 63419cd1..0d617ec9 100644 --- a/drafts/src/index.ts +++ b/drafts/src/index.ts @@ -61,6 +61,8 @@ export const plugins: Plugin[] = [ export type { Baseline, + CachedGroup, + ChangeGroupCacheDoc, CheckedOutDraft, CloneEntry, DraftDoc, diff --git a/drafts/src/providers/DraftListProvider.ts b/drafts/src/providers/DraftListProvider.ts index 58e87519..f136f377 100644 --- a/drafts/src/providers/DraftListProvider.ts +++ b/drafts/src/providers/DraftListProvider.ts @@ -23,6 +23,11 @@ import type { HasDrafts, } from "../draft-types.js"; import { SKIPPED_DATATYPES, canonicalUrl } from "../clone-policy.js"; +import { + createChangeGroupCacheFiller, + ensureMainDraft, + type TimelineSpec, +} from "../change-group-cache.js"; const ROOT_DOC_SELECTOR = "draft:root-doc"; const CHECKED_OUT_SELECTOR = "draft:checked-out"; @@ -98,9 +103,20 @@ export const DraftListProvider = (element: HTMLElement) => { const listSubscribers = new Set<(list: DraftList) => void>(); let orderedDraftUrls: AutomergeUrl[] = []; let draftList: DraftList = { - main: { url: docUrl, members: [], childCount: 0, name: null }, + main: { + url: docUrl, + members: [], + childCount: 0, + name: null, + changeGroupCacheUrl: null, + }, drafts: [], }; + // The write side of the change-group cache: keeps every timeline's + // ChangeGroupCacheDoc filled in background slices, whether or not the + // sidebar is open. Fed the current timelines after each list recompute; + // its own member-doc listeners drive incremental fills between recomputes. + const filler = createChangeGroupCacheFiller(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 @@ -154,6 +170,22 @@ export const DraftListProvider = (element: HTMLElement) => { if (disposed) return; hostDocHandle = handle; + // Eagerly create the main draft (a real doc, not the virtual fallback) so + // its change-group cache 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 { + await ensureMainDraft(repo, handle); + if (disposed) return; + } 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. @@ -239,6 +271,7 @@ export const DraftListProvider = (element: HTMLElement) => { return () => { disposed = true; + filler.dispose(); element.removeEventListener("patchwork:subscribe", onSubscribe); element.removeEventListener("patchwork:mounted", onMounted); element.removeEventListener("patchwork:unmounted", onUnmounted); @@ -311,15 +344,49 @@ export const DraftListProvider = (element: HTMLElement) => { } // Recompute the `draft:list` value and push it to subscribers when it - // actually changed. + // actually changed. The change-group cache filler follows the same + // recompute triggers, so its timeline set is reconciled here too. function recomputeList(): void { if (disposed) return; + syncFiller(); const next = computeList(); if (draftListsEqual(draftList, next)) return; draftList = next; for (const respond of listSubscribers) respond(next); } + // Hand the filler the current timelines, priority-ordered: main first, then + // the checked-out draft, then the remaining drafts in tree order. Main's + // timeline reads the main draft's identity clones (not the mounted-docs + // fallback) — membership reaches them via `syncMainDraftClones`. + function syncFiller(): void { + if (disposed) return; + const specs: TimelineSpec[] = []; + if (mainDraftHandle) { + specs.push({ + draftHandle: mainDraftHandle, + members: clonesToMembers(mainDraftHandle.doc()?.clones ?? {}), + rootDocUrl: docUrl, + }); + } + const selected = checkedOutHandle?.doc()?.checkedOut ?? null; + const ordered = + selected && orderedDraftUrls.includes(selected) + ? [selected, ...orderedDraftUrls.filter((u) => u !== selected)] + : orderedDraftUrls; + for (const url of ordered) { + const handle = trackedDrafts.get(url); + const doc = handle?.doc(); + if (!handle || !doc || doc.mergedAt !== undefined) continue; + specs.push({ + draftHandle: handle, + members: clonesToMembers(doc.clones), + rootDocUrl: docUrl, + }); + } + filler.sync(specs); + } + // The full read-only list: the main entry plus one summary per non-merged // draft, in rewalk (tree) order. function computeList(): DraftList { @@ -332,6 +399,7 @@ export const DraftListProvider = (element: HTMLElement) => { members: clonesToMembers(doc.clones), childCount: doc.drafts.length, name: doc.name ?? null, + changeGroupCacheUrl: doc.changeGroupCacheUrl ?? null, }); } return { main: computeMainSummary(), drafts }; @@ -345,17 +413,25 @@ export const DraftListProvider = (element: HTMLElement) => { const url = mainDraftHandle?.url ?? docUrl; const childCount = mainDraftHandle?.doc()?.drafts.length ?? 0; const name = mainDraftHandle?.doc()?.name ?? null; + const changeGroupCacheUrl = + mainDraftHandle?.doc()?.changeGroupCacheUrl ?? null; const mainClones = mainDraftHandle?.doc()?.clones; if (mainClones && Object.keys(mainClones).length > 0) { - return { url, members: clonesToMembers(mainClones), childCount, name }; + return { + url, + members: clonesToMembers(mainClones), + childCount, + name, + changeGroupCacheUrl, + }; } const members = [...mountCounts.keys()] .filter((u) => skipVerdicts.get(u) !== true) .map((u) => ({ url: u, cloneUrl: null, clonedAt: null })) .sort(byMemberUrl); - return { url, members, childCount, name }; + return { url, members, childCount, name, changeGroupCacheUrl }; } // The diff baseline for `target`, authoritative for both main and drafts: @@ -504,6 +580,7 @@ function summariesEqual(a: DraftSummary, b: DraftSummary): boolean { a.url === b.url && a.childCount === b.childCount && a.name === b.name && + a.changeGroupCacheUrl === b.changeGroupCacheUrl && memberListsEqual(a.members, b.members) ); } diff --git a/drafts/src/styles.css b/drafts/src/styles.css index b1d5137a..379eba74 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -14,8 +14,14 @@ --drafts-primary: var(--studio-primary-fill, var(--studio-primary, #3b82f6)); --drafts-primary-line: var(--studio-primary-line, var(--studio-fill, white)); --drafts-primary-fg: var(--drafts-primary-line); + /* The accent used as ink on the ambient background (accent-coloured text, + not a filled surface); contrast-tuned separately from the fill. */ + --drafts-primary-text: var(--studio-primary-text, #2563eb); --drafts-warning: var(--studio-warning-fill, var(--studio-warning, #f59e0b)); --drafts-warning-fg: var(--studio-warning-line, var(--studio-fill, white)); + /* Diff edit-size colors, from the theme's diff palette. */ + --drafts-added: var(--studio-added, #16a34a); + --drafts-deleted: var(--studio-deleted, #dc2626); --drafts-radius: var(--studio-radius-md, 0.5rem); --drafts-radius-sm: var(--studio-radius-sm, 0.25rem); --drafts-shadow-sm: var(--studio-shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05)); @@ -70,12 +76,12 @@ border: 1.5px dashed var(--drafts-primary); border-radius: var(--drafts-radius-sm); font-size: 0.75rem; - color: var(--drafts-primary); + color: var(--drafts-primary-text); text-align: center; } .drafts-dropzone[data-over] { - background: color-mix(in srgb, var(--drafts-primary) 12%, transparent); + background: color-mix(in oklch, var(--drafts-primary) 12%, transparent); } .drafts-btn { @@ -120,8 +126,8 @@ box-shadow: var(--drafts-shadow-sm); overflow: hidden; transition: - border-color 0.15s ease, - box-shadow 0.15s ease; + border-color var(--studio-transition, 0.15s ease), + box-shadow var(--studio-transition, 0.15s ease); } .draft-card[data-selected] { @@ -145,7 +151,7 @@ cursor: pointer; font-family: inherit; color: inherit; - transition: background 0.15s ease; + transition: background var(--studio-transition, 0.15s ease); } .draft-card-header:hover { @@ -224,7 +230,7 @@ font-family: inherit; font-size: 0.6875rem; line-height: 1.2; - color: var(--drafts-primary); + color: var(--drafts-primary-text); cursor: pointer; } @@ -325,11 +331,11 @@ /* Group backgrounds stay lightly translucent so the row highlight reads as a soft wash rather than a hard block. */ .draft-group-row:hover { - background: color-mix(in srgb, var(--drafts-hover-bg) 60%, transparent); + background: color-mix(in oklch, var(--drafts-hover-bg) 60%, transparent); } .draft-group-row[data-selected] { - background: color-mix(in srgb, var(--drafts-selected-bg) 60%, transparent); + background: color-mix(in oklch, var(--drafts-selected-bg) 60%, transparent); box-shadow: inset 0 0 0 1px var(--drafts-primary); } @@ -520,11 +526,11 @@ } .draft-count--add { - color: var(--studio-success, #16a34a); + color: var(--drafts-added); } .draft-count--del { - color: var(--studio-danger, #dc2626); + color: var(--drafts-deleted); } .drafts-version { From 0f7b5e69011fadb23f7b98472324b72db282872a Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Fri, 24 Jul 2026 15:52:30 +0200 Subject: [PATCH 08/18] add diff eye toggle: checkpoint baselines become the sole source of diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrubbing wrote from=to (no diff) and live drafts always diffed against their fork point via an implicit provider fallback, so diffs were neither visible when scrubbing nor controllable. The checkpoint's per-doc `from` is now the only baseline authority: an eye toggle in the card title writes it — group-start heads when pinned to a group (the baseline stays anchored while scrubbing within it), fork-point heads on a live draft — and the fallback is gone, so closing the eye simply removes the baselines. "Return to latest" moves left; the eye sits right. --- drafts/src/DraftsSidebar.tsx | 318 ++++++++++++++++++++-- drafts/src/draft-types.ts | 18 +- drafts/src/providers/DraftListProvider.ts | 35 +-- drafts/src/styles.css | 33 ++- 4 files changed, 353 insertions(+), 51 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 6bb65497..2a9dee77 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -53,7 +53,7 @@ const EMPTY_DRAFT_LIST: DraftList = { }; // Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.23"; +const DRAFTS_VERSION = "0.0.24"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -78,6 +78,21 @@ export function DraftsSidebar(props: { element: HTMLElement }) { () => checkedOut()?.checkedOut ?? null ); + // The eye toggle's state, derived from the checkpoint itself (no separate + // flag): the eye is open — diffs showing — iff the current checkpoint + // carries any diff baseline (`from`). + const eyeOpen = createMemo(() => { + const at = checkedOut()?.at; + return !!at && Object.values(at).some((e) => e.from !== undefined); + }); + + // Whether the view is actually pinned to history (any member has a `to`), + // as opposed to live-with-baseline (eye open, nothing pinned). + const isPinned = createMemo(() => { + const at = checkedOut()?.at; + return !!at && Object.values(at).some((e) => e.to !== undefined); + }); + // Where the scrubber sits: the change whose heads are displayed. Ephemeral, // client-only state: the stored checkpoint (`checkedOut.at`) is what // actually pins the view; this mirrors it to render the token and the @@ -129,7 +144,10 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // Apply a scrubber position: freeze every member doc at its heads as of the // scrub head. The token and row highlight update immediately; the - // checkpoint follows async. `draftUrl` is `null` for main. + // checkpoint follows async. `draftUrl` is `null` for main. With the eye + // open, the diff baseline anchors at the scrubbed group's start — it stays + // put while scrubbing within the group, and re-anchors when the head + // crosses into another group. const onScrub = ( draftUrl: AutomergeUrl | null, members: DraftMemberDoc[], @@ -139,9 +157,17 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const repo = getRepo(); if (!handle || !repo) return; setScrubber(scrub); + const base: CheckpointBase = eyeOpen() + ? { groupStartTime: scrub.groupStartTime } + : "none"; const seq = ++scrubSeq; void (async () => { - const checkpoint = await computeCheckpoint(repo, members, scrub.head); + const checkpoint = await computeCheckpoint( + repo, + members, + scrub.head, + base + ); // A newer scrub landed while this one was computing; drop it. if (seq !== scrubSeq) return; handle.change((d) => { @@ -151,16 +177,140 @@ export function DraftsSidebar(props: { element: HTMLElement }) { })(); }; + // The member docs of the current selection (`null` = main), for the eye + // and checkpoint handlers below. + const membersFor = (draftUrl: AutomergeUrl | null): DraftMemberDoc[] => + draftUrl + ? (list().drafts.find((s) => s.url === draftUrl)?.members ?? []) + : list().main.members; + + // The eye-open checkpoint for a live (unpinned) draft: every cloned member + // diffs against its fork point, nothing pinned. + const forkBaselines = (draftUrl: AutomergeUrl): DraftCheckpoint => { + const at: DraftCheckpoint = {}; + for (const member of membersFor(draftUrl)) { + if (member.clonedAt) at[member.url] = { from: member.clonedAt }; + } + return at; + }; + // Drop the time pin but stay on the same draft: back to live latest heads. + // With the eye open on a draft the diff baselines survive — the view goes + // live but keeps showing what changed since the fork point. (A live main + // has nothing to diff against, so there the eye closes with the pin.) const clearCheckpoint = () => { const handle = checkedOutHandle(); if (!handle) return; setScrubber(null); + const draftUrl = selected(); + const baselines = eyeOpen() && draftUrl ? forkBaselines(draftUrl) : null; handle.change((d) => { - d.at = null; + d.at = + baselines && Object.keys(baselines).length > 0 ? baselines : null; + }); + }; + + // The eye toggle: show or hide diff highlighting. The eye holds no state of + // its own — it rewrites the checkpoint's per-member `from`s (which the + // provider serves as `draft:baseline`), and its open/closed state is read + // back off the checkpoint (`eyeOpen`). + const toggleEye = () => { + const handle = checkedOutHandle(); + const repo = getRepo(); + if (!handle || !repo) return; + + if (eyeOpen()) { + // Close: strip the baselines; a pin (`to`) stays untouched. Baseline- + // only entries disappear entirely, so a live view's `at` goes to null. + handle.change((d) => { + if (!d.at) return; + const urls = Object.keys(d.at) as AutomergeUrl[]; + if (!urls.some((u) => d.at?.[u]?.to !== undefined)) { + d.at = null; + return; + } + for (const url of urls) { + const entry = d.at[url]; + if (!entry) continue; + if (entry.to === undefined) delete d.at[url]; + else delete entry.from; + } + }); + return; + } + + const draftUrl = selected(); + const s = scrubber(); + if (isPinned() && s) { + // Pinned with a known scrub position: recompute the checkpoint with + // the baseline anchored at the scrubbed group's start. + const members = membersFor(draftUrl); + const seq = ++scrubSeq; + void (async () => { + const checkpoint = await computeCheckpoint(repo, members, s.head, { + groupStartTime: s.groupStartTime, + }); + if (seq !== scrubSeq) return; + handle.change((d) => { + d.at = checkpoint; + }); + })(); + return; + } + + if (isPinned()) { + // Pinned but the scrub position is unknown (the sidebar remounted + // while the pin survived): fall back to diffing the pinned view + // against the fork point. + const clonedAt = new Map( + membersFor(draftUrl).map((m) => [m.url, m.clonedAt]) + ); + handle.change((d) => { + if (!d.at) return; + for (const url of Object.keys(d.at) as AutomergeUrl[]) { + const entry = d.at[url]; + if (!entry) continue; + entry.from = clonedAt.get(url) ?? encodeHeads([]); + } + }); + return; + } + + // Live on a draft: show what changed since the fork point. (Live on + // main the button is disabled — there is no baseline to diff against.) + if (!draftUrl) return; + const baselines = forkBaselines(draftUrl); + if (Object.keys(baselines).length === 0) return; + handle.change((d) => { + d.at = baselines; }); }; + // While the eye is open on a live (unpinned) draft, keep the baseline map + // in step with the member list: a doc forked after the eye was opened gets + // its fork-point baseline added here. (The provider serves baselines only + // from the checkpoint — there is no implicit fallback to cover it.) + createEffect(() => { + const at = checkedOut()?.at; + const draftUrl = selected(); + const handle = checkedOutHandle(); + if (!at || !draftUrl || !handle) return; + const entries = Object.values(at); + if (entries.length === 0) return; + if (entries.some((e) => e.to !== undefined)) return; // pinned: onScrub owns it + if (!entries.some((e) => e.from !== undefined)) return; // eye closed + const missing = membersFor(draftUrl).filter( + (m) => m.clonedAt !== null && !at[m.url] + ); + if (missing.length === 0) return; + handle.change((d) => { + if (!d.at) return; + for (const m of missing) { + if (m.clonedAt) d.at[m.url] = { from: m.clonedAt }; + } + }); + }); + const onCreateDraft = async () => { if (isFolder()) return; const docHandle = hostDocHandle(); @@ -206,8 +356,9 @@ export function DraftsSidebar(props: { element: HTMLElement }) { return; } - // Reuse the scrub machinery to resolve per-doc heads at this version. - const checkpoint = await computeCheckpoint(repo, members, head); + // Reuse the scrub machinery to resolve per-doc heads at this version + // (only the `to`s are read, so no diff baseline). + const checkpoint = await computeCheckpoint(repo, members, head, "none"); const clones: Record = {}; for (const member of members) { @@ -319,8 +470,11 @@ export function DraftsSidebar(props: { element: HTMLElement }) { head ? { members: list().main.members, head } : null ) } - hasCheckpoint={isMainSelected() && !!checkedOut()?.at} + hasCheckpoint={isMainSelected() && isPinned()} onReturnToLatest={clearCheckpoint} + eyeOpen={isMainSelected() && eyeOpen()} + eyeDisabled={!isPinned()} + onToggleEye={toggleEye} /> {(summary) => ( @@ -344,8 +498,11 @@ export function DraftsSidebar(props: { element: HTMLElement }) { head ? { members: summary.members, head } : null ) } - hasCheckpoint={selected() === summary.url && !!checkedOut()?.at} + hasCheckpoint={selected() === summary.url && isPinned()} onReturnToLatest={clearCheckpoint} + eyeOpen={selected() === summary.url && eyeOpen()} + eyeDisabled={false} + onToggleEye={toggleEye} /> )} @@ -725,6 +882,9 @@ function MainCard(props: { onDragVersion: (head: ChangeRef | null) => void; hasCheckpoint: boolean; onReturnToLatest: () => void; + eyeOpen: boolean; + eyeDisabled: boolean; + onToggleEye: () => void; }) { return (
@@ -741,10 +901,10 @@ function MainCard(props: { fallback="Main" onRename={props.onRename} /> - {/* Shown in the title (where the "current" badge used to sit) while - the timeline is pinned: drops the pin and returns to the live - latest heads. It lives inside the clickable header, so the click - is stopped from also re-selecting the card. */} + {/* Shown left of the eye while the timeline is pinned: drops the + pin and returns to the live latest heads. It lives inside the + clickable header, so the click is stopped from also + re-selecting the card. */}
@@ -789,6 +956,9 @@ function DraftCard(props: { onDragVersion: (head: ChangeRef | null) => void; hasCheckpoint: boolean; onReturnToLatest: () => void; + eyeOpen: boolean; + eyeDisabled: boolean; + onToggleEye: () => void; }) { return (
@@ -818,6 +988,13 @@ function DraftCard(props: { Return to latest + + +
@@ -880,6 +1057,76 @@ function DraftName(props: { ); } +// The diff toggle in a card title, right-aligned. Open = diff highlighting +// showing. It holds no state: open/closed is derived from the checkpoint's +// baselines and toggling rewrites them (see `toggleEye`). Disabled on a live +// main view, where there is nothing to diff against. +function EyeToggle(props: { + open: boolean; + disabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function EyeIcon() { + return ( + + + + + ); +} + +function EyeOffIcon() { + return ( + + + + + ); +} + // A reference to one change in the interleaved timeline, by document and // hash. `time` steers how the *other* member docs' heads are resolved around // it (see `computeCheckpoint`). @@ -893,10 +1140,13 @@ type ChangeRef = { // anchored to its cached group. `offset` is the change's position within the // group, 0 = the group's newest change (what the scrubber geometry snaps // to); `head` identifies the exact change for the checkpoint machinery. +// `groupStartTime` is the group's span start, carried so the checkpoint's +// diff baseline can anchor at the group's beginning while the eye is on. type ScrubberState = { groupId: string; offset: number; head: ChangeRef; + groupStartTime: number; }; // One change recovered by the on-demand scrub-resolution scan. `doc` is the @@ -1069,6 +1319,7 @@ function DraftChangesList(props: { hash: group.newestHash, time: group.endTime, }, + groupStartTime: group.startTime, }); return; } @@ -1079,6 +1330,7 @@ function DraftChangesList(props: { groupId: group.id, offset, head: { docUrl: row.docUrl, hash: row.hash, time: row.time }, + groupStartTime: group.startTime, }); }; @@ -1457,18 +1709,25 @@ function EditCounts(props: { additions: number; deletions: number }) { ); } +// Diff baseline mode for a checkpoint. `"none"` writes no `from` (no diff); +// `{ groupStartTime }` anchors each member's `from` at its state just before +// the scrubbed group began, so the whole group reads as the diff. +type CheckpointBase = "none" | { groupStartTime: number }; + // Build the checkpoint map for a scrub position. Each member's displayed // version (`to`) is its heads as of `head`: the doc that owns that change is // pinned exactly to it, every other member to its latest change at or before -// it (approximate but good enough). The diff baseline (`from`) is always the -// displayed heads themselves (no diff) — set explicitly (rather than -// omitted) so a draft doesn't fall back to its fork-point baseline and light -// up the whole draft diff. Members with no change at or before `head` are -// omitted entirely: they didn't exist yet, so they fall through to live. +// it (approximate but good enough). The diff baseline (`from`) follows +// `base`: omitted for `"none"`, or the member's heads just before the +// group's start (falling back to the fork point — empty heads on main — when +// no post-fork change precedes the group). Members with no change at or +// before `head` are omitted entirely: they didn't exist yet, so they fall +// through to live. async function computeCheckpoint( repo: Repo, members: DraftMemberDoc[], - head: ChangeRef + head: ChangeRef, + base: CheckpointBase ): Promise { const checkpoint: DraftCheckpoint = {}; for (const member of members) { @@ -1499,7 +1758,28 @@ async function computeCheckpoint( to = encodeHeads([metas[pinnedIndex].hash]); } - checkpoint[member.url] = { from: to, to }; + if (base === "none") { + checkpoint[member.url] = { to }; + continue; + } + + // Diff baseline: the member's newest change strictly before the group + // began. None post-fork means the group starts the member's history in + // this timeline, so diff against the fork point (empty heads on main — + // the whole doc reads as added). + let fromIndex = -1; + let fromTime = -Infinity; + metas.forEach((m, i) => { + if (m.time < base.groupStartTime && m.time >= fromTime) { + fromTime = m.time; + fromIndex = i; + } + }); + const from = + fromIndex >= 0 + ? encodeHeads([metas[fromIndex].hash]) + : (member.clonedAt ?? encodeHeads([])); + checkpoint[member.url] = { from, to }; } catch (err) { console.warn( "[drafts] failed to compute checkpoint for member:", diff --git a/drafts/src/draft-types.ts b/drafts/src/draft-types.ts index 84b44c5b..9b7c1a16 100644 --- a/drafts/src/draft-types.ts +++ b/drafts/src/draft-types.ts @@ -77,8 +77,10 @@ export type ChangeGroupCacheDoc = { // One member doc's pinned view within a checkpoint. `to` is the heads to render // the doc at (a fixed-heads, read-only view); omit it to leave the doc live. // `from` is the diff baseline the consumer compares `to` (or live) against; -// omit it for no diff. A doc whose pinned change is its first has no predecessor, -// so `from` is `[]` (the whole doc reads as added). +// omit it for no diff. `from` without `to` is the live-with-diff state (the +// sidebar's eye toggled on while nothing is pinned: the doc stays writable and +// diffs against its fork point). A `from` of `[]` diffs against the empty doc, +// so the whole doc reads as added. export type DocCheckpoint = { from?: UrlHeads; to?: UrlHeads; @@ -101,8 +103,10 @@ export type DraftCheckpoint = Record; // itself, no draft overlay. The derived drafts list lives separately in the // read-only `draft:list` push (`DraftList`). // -// `at` pins the checkout to a history entry: absent/null means the live latest -// heads (the default), set means a frozen read-only view (see DraftCheckpoint). +// `at` carries the checkout's checkpoint (see DraftCheckpoint): per-doc pinned +// heads (`to`) and/or diff baselines (`from`). Absent/null means the live +// latest heads with no diff (the default). Entries with only `from` leave the +// docs live but diffing — the sidebar's eye toggle on an unpinned draft. export type CheckedOutDraft = { checkedOut: AutomergeUrl | null; at?: DraftCheckpoint | null; @@ -110,9 +114,9 @@ export type CheckedOutDraft = { // Response shape for `draft:baseline { url }`, served by the draft-list provider // (see `currentBaseline`). `heads` is the doc's diff baseline: the checkpoint's -// per-doc `from` when a history entry is pinned, otherwise the checked-out -// draft's fork-point heads (`clones[url].clonedAt`) for a live draft view. -// `heads` is `null` when there is no baseline (no clone yet, no pin on main). +// per-doc `from`, written by the sidebar's eye toggle and scrubber. `heads` is +// `null` when the checkpoint has no entry for the doc or the entry carries no +// `from` — there is no implicit fork-point fallback; no baseline means no diff. // It is `null` rather than optional so the value is a valid structured-cloneable // `JSONValue` crossing the provider channel. export type Baseline = { diff --git a/drafts/src/providers/DraftListProvider.ts b/drafts/src/providers/DraftListProvider.ts index f136f377..8b96e4a8 100644 --- a/drafts/src/providers/DraftListProvider.ts +++ b/drafts/src/providers/DraftListProvider.ts @@ -90,9 +90,8 @@ export const DraftListProvider = (element: HTMLElement) => { // `draft:baseline` subscribers, keyed by canonical target url. This provider // is the sole answerer (the overlay no longer claims it), serving the - // checkpoint's per-doc `from` when pinned and the checked-out draft's - // fork point otherwise (see `currentBaseline`). Re-emitted whenever the - // checkout doc or a tracked draft's clones change. + // checkpoint's per-doc `from` (see `currentBaseline`). Re-emitted whenever + // the checkout doc changes. const baselineSubscribers = new Map< AutomergeUrl, Set<(baseline: Baseline) => void> @@ -131,12 +130,10 @@ export const DraftListProvider = (element: HTMLElement) => { let rewalkInFlight = false; let rewalkPending = false; // 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. A new - // clone also gives a checked-out draft a fork-point baseline, so re-publish. + // rewalk) or its clone map grew (needs a list recompute), so do both. const onTrackedChange = () => { scheduleRewalk(); recomputeList(); - notifyBaselines(); }; const onHostDocChange = () => scheduleRewalk(); // The checkout doc changed: its `at` checkpoint may have been set, cleared, or @@ -243,8 +240,8 @@ export const DraftListProvider = (element: HTMLElement) => { if (type === BASELINE_SELECTOR) { // Sole answerer for `draft:baseline` (the overlay no longer claims it): - // serves the pinned checkpoint's `from` or the checked-out draft's fork - // point for `target` (see `currentBaseline`). + // serves the checkpoint's per-doc `from` for `target` (see + // `currentBaseline`). const rawTarget = (event.detail.selector as { url?: unknown }).url; if (typeof rawTarget !== "string" || !isValidAutomergeUrl(rawTarget)) { return; @@ -434,22 +431,14 @@ export const DraftListProvider = (element: HTMLElement) => { return { url, members, childCount, name, changeGroupCacheUrl }; } - // The diff baseline for `target`, authoritative for both main and drafts: - // - a pinned checkpoint honors that doc's `from` exactly (`null` = no diff); - // - otherwise, on a draft, the doc diffs against its clone's fork point; - // - otherwise (main, no pin) there is no baseline. + // The diff baseline for `target`: the checkpoint's per-doc `from`, written + // by the sidebar (its eye toggle and scrubber). `null` — no entry, or an + // entry without `from` — means no diff. There is deliberately no implicit + // 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 doc = checkedOutHandle?.doc(); - const entry = doc?.at?.[target]; - if (entry) return { heads: entry.from ?? null }; - - const checkedOut = doc?.checkedOut; - if (checkedOut) { - const clonedAt = trackedDrafts.get(checkedOut)?.doc()?.clones?.[target] - ?.clonedAt; - return { heads: clonedAt ?? null }; - } - return { heads: null }; + const entry = checkedOutHandle?.doc()?.at?.[target]; + return { heads: entry?.from ?? null }; } function notifyBaselines(): void { diff --git a/drafts/src/styles.css b/drafts/src/styles.css index 379eba74..884a2fa0 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -218,11 +218,10 @@ max-height: 32rem; } -/* Sits in the card title (where the "current" badge used to be) while the +/* Sits in the card title, left-aligned right after the name, while the timeline is pinned; drops the pin and returns to the latest version. */ .draft-card-return { flex-shrink: 0; - margin-left: auto; padding: 0.125rem 0.5rem; border: 1px solid var(--drafts-border); border-radius: var(--studio-radius-round, 9999px); @@ -238,6 +237,36 @@ background: var(--drafts-hover-bg); } +/* The diff toggle (eye), right-aligned in the card title. Active = diff + highlighting showing (the checkpoint carries baselines). */ +.draft-card-eye { + flex-shrink: 0; + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: none; + border-radius: var(--studio-radius, 0.25rem); + background: transparent; + color: var(--drafts-faint-fg); + cursor: pointer; +} + +.draft-card-eye:hover:not(:disabled) { + background: var(--drafts-hover-bg); + color: var(--drafts-primary-text); +} + +.draft-card-eye[data-active] { + color: var(--drafts-primary-text); +} + +.draft-card-eye:disabled { + opacity: 0.35; + cursor: default; +} + .draft-changes-empty { font-size: 0.75rem; color: var(--drafts-faint-fg); From 7bdb8095c0106074f972a6156740a1e78a61696c Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Fri, 24 Jul 2026 16:52:03 +0200 Subject: [PATCH 09/18] add draggable diff baseline: a second scrubber handle anchors the diff's from point Plus timeline polish: selecting a group re-anchors the baseline to its start, GitHub-style relative times, softer scrubber lines and selection styling, no group-row highlight, 10-minute grouping gap, and a tooltip on the disabled eye. --- drafts/src/DraftsSidebar.tsx | 406 +++++++++++++++++++++++++------ drafts/src/change-group-cache.ts | 6 +- drafts/src/styles.css | 63 ++++- 3 files changed, 389 insertions(+), 86 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 2a9dee77..3fe291b3 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -53,7 +53,7 @@ const EMPTY_DRAFT_LIST: DraftList = { }; // Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.24"; +const DRAFTS_VERSION = "0.0.28"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -100,6 +100,13 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // survives). const [scrubber, setScrubber] = createSignal(null); + // Where the diff baseline handle sits: an absolute point in history the + // diff is measured from, independent of the head (`scrubber`). Ephemeral + // like the head — resets on reload, while the persisted `from` keeps + // driving the editor diff. Non-null only while the eye is open and a + // version is pinned; that is also exactly when the handle renders. + const [baseliner, setBaseliner] = createSignal(null); + // A version being dragged out of a history timeline (from a group row or // the scrubber sticker). While set, the actions area shows a drop zone // that forks a new draft at that version; cleared on drop or dragend. @@ -128,6 +135,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const handle = checkedOutHandle(); if (!handle) return; setScrubber(null); + setBaseliner(null); handle.change((d) => { d.checkedOut = url; // Switching drafts (or to main) returns to the live latest heads. @@ -142,32 +150,27 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // scrub position (a drag fires one recompute per snapped change). let scrubSeq = 0; - // Apply a scrubber position: freeze every member doc at its heads as of the - // scrub head. The token and row highlight update immediately; the - // checkpoint follows async. `draftUrl` is `null` for main. With the eye - // open, the diff baseline anchors at the scrubbed group's start — it stays - // put while scrubbing within the group, and re-anchors when the head - // crosses into another group. - const onScrub = ( + // Recompute and persist the checkpoint from the current head (`scrubber`) + // and baseline (`baseliner`) signals: `to`s follow the head, `from`s follow + // the baseline (absolute, independent of the head). With the eye closed the + // baseline is null and no `from` is written. The token and row highlight + // update immediately from the signals; the checkpoint follows async. + // `draftUrl` is `null` for main. Guarded by `scrubSeq` so a slower + // computation can't clobber a newer drag. + const applyPins = ( draftUrl: AutomergeUrl | null, - members: DraftMemberDoc[], - scrub: ScrubberState + members: DraftMemberDoc[] ) => { const handle = checkedOutHandle(); const repo = getRepo(); if (!handle || !repo) return; - setScrubber(scrub); - const base: CheckpointBase = eyeOpen() - ? { groupStartTime: scrub.groupStartTime } - : "none"; + const head = scrubber(); + if (!head) return; + const bl = baseliner(); + const base: CheckpointBase = bl ? { beforeTime: bl.time } : "none"; const seq = ++scrubSeq; void (async () => { - const checkpoint = await computeCheckpoint( - repo, - members, - scrub.head, - base - ); + const checkpoint = await computeCheckpoint(repo, members, head.head, base); // A newer scrub landed while this one was computing; drop it. if (seq !== scrubSeq) return; handle.change((d) => { @@ -177,6 +180,27 @@ export function DraftsSidebar(props: { element: HTMLElement }) { })(); }; + // Move the head (the version being viewed); the baseline stays where it is. + const onScrub = ( + draftUrl: AutomergeUrl | null, + members: DraftMemberDoc[], + scrub: ScrubberState + ) => { + setScrubber(scrub); + applyPins(draftUrl, members); + }; + + // Move the diff baseline; the head stays where it is. Only reachable while + // the eye is open and pinned (that is when the handle renders). + const onBaselineScrub = ( + draftUrl: AutomergeUrl | null, + members: DraftMemberDoc[], + base: BaselineState + ) => { + setBaseliner(base); + applyPins(draftUrl, members); + }; + // The member docs of the current selection (`null` = main), for the eye // and checkpoint handlers below. const membersFor = (draftUrl: AutomergeUrl | null): DraftMemberDoc[] => @@ -202,6 +226,8 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const handle = checkedOutHandle(); if (!handle) return; setScrubber(null); + // Unpinned: the baseline handle only shows while pinned, so drop it. + setBaseliner(null); const draftUrl = selected(); const baselines = eyeOpen() && draftUrl ? forkBaselines(draftUrl) : null; handle.change((d) => { @@ -222,6 +248,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { if (eyeOpen()) { // Close: strip the baselines; a pin (`to`) stays untouched. Baseline- // only entries disappear entirely, so a live view's `at` goes to null. + setBaseliner(null); handle.change((d) => { if (!d.at) return; const urls = Object.keys(d.at) as AutomergeUrl[]; @@ -242,13 +269,20 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const draftUrl = selected(); const s = scrubber(); if (isPinned() && s) { - // Pinned with a known scrub position: recompute the checkpoint with - // the baseline anchored at the scrubbed group's start. + // Pinned with a known scrub position: seed the baseline handle at the + // scrubbed group's start and recompute the checkpoint against it. The + // handle's exact offset is resolved by the changes list, which knows + // the group's size (BASELINE_GROUP_START). + setBaseliner({ + groupId: s.groupId, + offset: BASELINE_GROUP_START, + time: s.groupStartTime, + }); const members = membersFor(draftUrl); const seq = ++scrubSeq; void (async () => { const checkpoint = await computeCheckpoint(repo, members, s.head, { - groupStartTime: s.groupStartTime, + beforeTime: s.groupStartTime, }); if (seq !== scrubSeq) return; handle.change((d) => { @@ -465,6 +499,10 @@ export function DraftsSidebar(props: { element: HTMLElement }) { onSelect={() => selectDraft(null)} onScrub={(scrub) => onScrub(null, list().main.members, scrub)} scrubber={() => (isMainSelected() ? scrubber() : null)} + onBaselineScrub={(base) => + onBaselineScrub(null, list().main.members, base) + } + baseliner={() => (isMainSelected() ? baseliner() : null)} onDragVersion={(head) => setDragVersion( head ? { members: list().main.members, head } : null @@ -493,6 +531,12 @@ export function DraftsSidebar(props: { element: HTMLElement }) { scrubber={() => selected() === summary.url ? scrubber() : null } + onBaselineScrub={(base) => + onBaselineScrub(summary.url, summary.members, base) + } + baseliner={() => + selected() === summary.url ? baseliner() : null + } onDragVersion={(head) => setDragVersion( head ? { members: summary.members, head } : null @@ -879,6 +923,8 @@ function MainCard(props: { onSelect: () => void; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; + onBaselineScrub: (base: BaselineState) => void; + baseliner: Accessor; onDragVersion: (head: ChangeRef | null) => void; hasCheckpoint: boolean; onReturnToLatest: () => void; @@ -934,6 +980,9 @@ function MainCard(props: { mainDocUrl={props.hostDocUrl} onScrub={props.onScrub} scrubber={props.scrubber} + onBaselineScrub={props.onBaselineScrub} + baseliner={props.baseliner} + eyeOpen={() => props.eyeOpen} onDragVersion={props.onDragVersion} onReturnToLatest={props.onReturnToLatest} /> @@ -953,6 +1002,8 @@ function DraftCard(props: { onSelect: (url: AutomergeUrl) => void; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; + onBaselineScrub: (base: BaselineState) => void; + baseliner: Accessor; onDragVersion: (head: ChangeRef | null) => void; hasCheckpoint: boolean; onReturnToLatest: () => void; @@ -1004,6 +1055,9 @@ function DraftCard(props: { mainDocUrl={props.mainDocUrl} onScrub={props.onScrub} scrubber={props.scrubber} + onBaselineScrub={props.onBaselineScrub} + baseliner={props.baseliner} + eyeOpen={() => props.eyeOpen} onDragVersion={props.onDragVersion} onReturnToLatest={props.onReturnToLatest} /> @@ -1060,7 +1114,9 @@ function DraftName(props: { // The diff toggle in a card title, right-aligned. Open = diff highlighting // showing. It holds no state: open/closed is derived from the checkpoint's // baselines and toggling rewrites them (see `toggleEye`). Disabled on a live -// main view, where there is nothing to diff against. +// main view, where there is nothing to diff against — via aria-disabled +// rather than the disabled attribute, so the explanatory tooltip still shows +// on hover (browsers don't reliably show titles on disabled buttons). function EyeToggle(props: { open: boolean; disabled: boolean; @@ -1071,14 +1127,16 @@ function EyeToggle(props: { type="button" class="draft-card-eye" data-active={props.open ? "" : undefined} - disabled={props.disabled} + data-disabled={props.disabled ? "" : undefined} + aria-disabled={props.disabled} onClick={(e) => { e.stopPropagation(); + if (props.disabled) return; props.onToggle(); }} title={ props.disabled - ? "Nothing to diff against — pin a version first" + ? "Nothing to compare yet — select a version in the timeline first" : props.open ? "Hide changes" : "Show what changed" @@ -1149,6 +1207,23 @@ type ScrubberState = { groupStartTime: number; }; +// Where the diff baseline handle sits: an absolute point in history the diff +// is measured from, independent of the head. `time` resolves each member's +// `from` (see `computeCheckpoint`); `groupId`/`offset` place the handle in the +// track (same geometry as the head). `offset` of `BASELINE_GROUP_START` means +// "the start of the group" — the changes list resolves it to the group's +// oldest change once it knows the group's size. Ephemeral like `ScrubberState`. +type BaselineState = { + groupId: string; + offset: number; + time: number; +}; + +// Sentinel `BaselineState.offset` meaning "the group's start" (its oldest +// change / the bottom of its band), used when the eye seeds the handle before +// the group's change count is known. +const BASELINE_GROUP_START = -1; + // One change recovered by the on-demand scrub-resolution scan. `doc` is the // member doc it was read from (kept so the sticker can diff it on demand); // `seq` is the change's per-document causal index, used only to break @@ -1182,6 +1257,9 @@ function DraftChangesList(props: { mainDocUrl: AutomergeUrl | undefined; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; + onBaselineScrub: (base: BaselineState) => void; + baseliner: Accessor; + eyeOpen: Accessor; onDragVersion: (head: ChangeRef | null) => void; onReturnToLatest: () => void; }) { @@ -1306,12 +1384,15 @@ function DraftChangesList(props: { return rows; }; - // Scrub so the head sits at `offset` within `group` (0 = the group's + // Build the head scrub state for `offset` within `group` (0 = the group's // newest change, which the cache anchors directly; deeper offsets resolve - // through the on-demand scan). - const scrubTo = (group: CachedGroup, offset: number) => { + // through the on-demand scan). Null while the scan is still resolving. + const buildHead = ( + group: CachedGroup, + offset: number + ): ScrubberState | null => { if (offset <= 0) { - props.onScrub({ + return { groupId: group.id, offset: 0, head: { @@ -1320,17 +1401,107 @@ function DraftChangesList(props: { time: group.endTime, }, groupStartTime: group.startTime, - }); - return; + }; } const rows = resolveGroupChanges(group); - if (!rows || rows.length === 0) return; + if (!rows || rows.length === 0) return null; const row = rows[Math.min(offset, rows.length - 1)]; - props.onScrub({ + return { groupId: group.id, offset, head: { docUrl: row.docUrl, hash: row.hash, time: row.time }, groupStartTime: group.startTime, + }; + }; + + // Expand the BASELINE_GROUP_START sentinel to the group's oldest change. + const resolveOffset = (group: CachedGroup, offset: number): number => + offset === BASELINE_GROUP_START + ? Math.max(0, group.changeCount - 1) + : offset; + + // The change time at `offset` within `group` (0 = the group's newest + // change). Used to resolve the baseline handle's `from` time. + const timeAt = (group: CachedGroup, offset: number): number => { + const resolved = resolveOffset(group, offset); + if (resolved <= 0) return group.endTime; + const rows = resolveGroupChanges(group); + if (!rows || rows.length === 0) return group.startTime; + return rows[Math.min(resolved, rows.length - 1)].time; + }; + + // A position's rank in the flat change order (higher = older), so the head + // and baseline can be compared for clamping. Offsets are resolved (sentinel + // -> group start) and clamped within their group. + const linearIndex = (groupId: string, offset: number): number => { + let acc = 0; + for (const b of bands()) { + const count = Math.max(1, b.group.changeCount); + if (b.group.id === groupId) { + return acc + Math.min(Math.max(0, resolveOffset(b.group, offset)), count - 1); + } + acc += count; + } + return acc + Math.max(0, offset); + }; + + // Move the head to `offset` within `group`. With the eye open the baseline + // stays put (absolute), except that dragging the head older than the + // baseline pushes the baseline down with it — they can meet but not cross. + // The baseline is pushed first so the diff never momentarily inverts. + const scrubTo = (group: CachedGroup, offset: number) => { + const head = buildHead(group, offset); + if (!head) return; + if (props.eyeOpen()) { + const bl = props.baseliner(); + if ( + bl && + linearIndex(head.groupId, head.offset) > + linearIndex(bl.groupId, bl.offset) + ) { + props.onBaselineScrub({ + groupId: group.id, + offset: head.offset, + time: head.head.time, + }); + } + } + props.onScrub(head); + }; + + // Select a group (click on its row): the head pins to the group's newest + // change, and — with the eye open — the baseline re-anchors to the group's + // start, so the diff reads "everything this group changed". Dragging the + // head, by contrast, leaves the baseline where it is (see `scrubTo`). + const selectGroup = (group: CachedGroup) => { + if (props.eyeOpen()) { + props.onBaselineScrub({ + groupId: group.id, + offset: BASELINE_GROUP_START, + time: group.startTime, + }); + } + scrubTo(group, 0); + }; + + // Move the baseline to `offset` within `group`, clamped so it never crosses + // above (newer than) the head — the diff always reads old -> new. When the + // clamp bites, the baseline snaps to the head (an empty diff). + const baselineScrubTo = (group: CachedGroup, offset: number) => { + const head = props.scrubber(); + if (!head) return; + if (linearIndex(group.id, offset) < linearIndex(head.groupId, head.offset)) { + props.onBaselineScrub({ + groupId: head.groupId, + offset: head.offset, + time: head.head.time, + }); + return; + } + props.onBaselineScrub({ + groupId: group.id, + offset, + time: timeAt(group, offset), }); }; @@ -1344,13 +1515,6 @@ function DraftChangesList(props: { ) ?? null; - const groupContainsHead = (group: CachedGroup): boolean => { - const s = props.scrubber(); - if (!s) return false; - if (s.groupId === group.id) return true; - return s.head.time >= group.startTime && s.head.time <= group.endTime; - }; - // --- Scrubber geometry --------------------------------------------------- // The track mirrors the rows column: each group row is one vertical band, // and the group's changes distribute evenly across the band's height, so @@ -1441,6 +1605,21 @@ function DraftChangesList(props: { return { top: yForPosition(band, s.offset) }; }); + // The baseline handle's y in the track, or null when it should not show: + // only while the eye is open and a version is pinned (a head is set). Idle + // on the latest version there is no baseline to drag. + const baselineGeometry = createMemo(() => { + if (!props.eyeOpen() || !props.scrubber()) return null; + const b = props.baseliner(); + if (!b) return null; + const bs = bands(); + const band = + bs.find((x) => x.group.id === b.groupId) ?? + bs.find((x) => b.time >= x.group.startTime && b.time <= x.group.endTime); + if (!band) return null; + return { top: yForPosition(band, resolveOffset(band.group, b.offset)) }; + }); + let trackEl: HTMLDivElement | undefined; // Pointer y relative to the track's top edge. The rect is re-read per event @@ -1491,6 +1670,38 @@ function DraftChangesList(props: { target.addEventListener("pointercancel", onUp); }; + // Begin a baseline-handle drag: the baseline follows the pointer, clamped so + // it never crosses above the head (see `baselineScrubTo`). Unlike the head, + // it has no return-to-latest gesture — the baseline is only meaningful while + // pinned, so there is nowhere at the top to drop it. + const beginBaselineDrag = (ev: PointerEvent) => { + if (!trackEl || bands().length === 0) return; + ev.preventDefault(); + ev.stopPropagation(); + const grabOffset = yInTrack(ev) - (baselineGeometry()?.top ?? 0); + + let last: string | null = null; + const onMove = (e: PointerEvent) => { + const pos = positionForY(yInTrack(e) - grabOffset); + if (!pos) return; + const key = `${pos.group.id}:${pos.offset}`; + if (key === last) return; + last = key; + baselineScrubTo(pos.group, pos.offset); + }; + + const target = ev.currentTarget as HTMLElement; + target.setPointerCapture(ev.pointerId); + const onUp = () => { + target.removeEventListener("pointermove", onMove); + target.removeEventListener("pointerup", onUp); + target.removeEventListener("pointercancel", onUp); + }; + target.addEventListener("pointermove", onMove); + target.addEventListener("pointerup", onUp); + target.addEventListener("pointercancel", onUp); + }; + // Start a native drag carrying a version out of the timeline. The payload // rides in a component signal (source and drop zone share the panel); // dataTransfer is only set so the browser actually starts the drag. @@ -1559,8 +1770,7 @@ function DraftChangesList(props: { rowEls.set(group.id, el)} - isSelected={groupContainsHead(group)} - onSelect={() => scrubTo(group, 0)} + onSelect={() => selectGroup(group)} onVersionDragStart={(e) => beginVersionDrag(e, { docUrl: group.newestMemberUrl, @@ -1623,6 +1833,35 @@ function DraftChangesList(props: {
+ {/* The diff baseline: a second handle (line + hollow circle) shown + only with the eye open and a version pinned. Independent of the + head — dragging it re-anchors the diff's older bound — and joined + to the head by a connector so the two read as one range. */} + +
+
+
+
+
+
+
@@ -1631,15 +1870,14 @@ function DraftChangesList(props: { // One time group, rendered as a single non-expandable row: author avatars, // the group's newest timestamp, and the aggregated +/- counts. Clicking the -// row parks the scrubber at the top of the group. The row highlights while -// the scrubber head sits inside the group. Dragging the row out forks a new -// draft at the group's newest change (the same version clicking pins); -// dragstart only fires past the movement threshold, so click-to-select is -// unaffected. +// row parks the scrubber at the top of the group (the scrubber token is the +// selection indicator — the row itself doesn't highlight). Dragging the row +// out forks a new draft at the group's newest change (the same version +// clicking pins); dragstart only fires past the movement threshold, so +// click-to-select is unaffected. function TimeGroupRow(props: { group: CachedGroup; rowRef: (el: HTMLElement) => void; - isSelected: boolean; onSelect: () => void; onVersionDragStart: (e: DragEvent) => void; onVersionDragEnd: () => void; @@ -1649,7 +1887,6 @@ function TimeGroupRow(props: { type="button" class="draft-group-row" ref={props.rowRef} - data-selected={props.isSelected ? "" : undefined} title="View the draft as of this group — drag out to fork a draft from it" onClick={props.onSelect} draggable={true} @@ -1710,19 +1947,22 @@ function EditCounts(props: { additions: number; deletions: number }) { } // Diff baseline mode for a checkpoint. `"none"` writes no `from` (no diff); -// `{ groupStartTime }` anchors each member's `from` at its state just before -// the scrubbed group began, so the whole group reads as the diff. -type CheckpointBase = "none" | { groupStartTime: number }; +// `{ beforeTime }` anchors each member's `from` at its state just before that +// absolute time — the draggable baseline handle's position — so everything +// from there up to the head reads as the diff. The baseline is independent of +// the head (see `onScrub`/`onBaselineScrub`): the two are resolved separately +// and only clamped so the baseline stays at or older than the head. +type CheckpointBase = "none" | { beforeTime: number }; // Build the checkpoint map for a scrub position. Each member's displayed // version (`to`) is its heads as of `head`: the doc that owns that change is // pinned exactly to it, every other member to its latest change at or before // it (approximate but good enough). The diff baseline (`from`) follows -// `base`: omitted for `"none"`, or the member's heads just before the -// group's start (falling back to the fork point — empty heads on main — when -// no post-fork change precedes the group). Members with no change at or -// before `head` are omitted entirely: they didn't exist yet, so they fall -// through to live. +// `base`: omitted for `"none"`, or the member's heads just before +// `beforeTime` (falling back to the fork point — empty heads on main — when +// no post-fork change precedes it). Members with no change at or before +// `head` are omitted entirely: they didn't exist yet, so they fall through to +// live. async function computeCheckpoint( repo: Repo, members: DraftMemberDoc[], @@ -1763,14 +2003,14 @@ async function computeCheckpoint( continue; } - // Diff baseline: the member's newest change strictly before the group - // began. None post-fork means the group starts the member's history in - // this timeline, so diff against the fork point (empty heads on main — - // the whole doc reads as added). + // Diff baseline: the member's newest change strictly before the + // baseline's time. None post-fork means the baseline sits at or before + // the start of the member's history in this timeline, so diff against + // the fork point (empty heads on main — the whole doc reads as added). let fromIndex = -1; let fromTime = -Infinity; metas.forEach((m, i) => { - if (m.time < base.groupStartTime && m.time >= fromTime) { + if (m.time < base.beforeTime && m.time >= fromTime) { fromTime = m.time; fromIndex = i; } @@ -1846,19 +2086,41 @@ function getInitials(authorId: string): string { return authorId.slice(0, 2).toUpperCase(); } -// Format an Automerge change time (Unix SECONDS) as a short local timestamp. +// Ticks so relative timestamps ("5 minutes ago") stay fresh while the panel +// is open. Module-level: one timer serves every row of every card, and it +// lives as long as the module does. +const [nowMs, setNowMs] = createSignal(Date.now()); +setInterval(() => setNowMs(Date.now()), 30_000); + +// `numeric: "auto"` gives GitHub's phrasing: "yesterday" and "last week" +// instead of "1 day ago" and "1 week ago". +const RELATIVE_TIME = new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", +}); + +// Format an Automerge change time (Unix SECONDS) the way GitHub's +// element formats timeline times: relative while recent +// ("now", "5 minutes ago", "5 hours ago", "yesterday", "last week"), and +// past 30 days an absolute short date ("Jun 13"), with the year appended +// only once it differs from the current one ("Jun 13, 2025"). function formatTime(timeSeconds: number): string { if (!timeSeconds) return ""; const date = new Date(timeSeconds * 1000); - const datePart = date.toLocaleDateString(undefined, { + const seconds = Math.max(0, Math.floor((nowMs() - date.getTime()) / 1000)); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + if (minutes < 1) return "now"; + if (hours < 1) return RELATIVE_TIME.format(-minutes, "minute"); + if (days < 1) return RELATIVE_TIME.format(-hours, "hour"); + if (days < 7) return RELATIVE_TIME.format(-days, "day"); + if (days < 30) return RELATIVE_TIME.format(-Math.floor(days / 7), "week"); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric", + year: + date.getFullYear() === new Date(nowMs()).getFullYear() + ? undefined + : "numeric", }); - const timePart = date.toLocaleTimeString(undefined, { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - return `${datePart}, ${timePart}`; } diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts index 8ed14878..f07bdc40 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -22,9 +22,9 @@ export const CHANGE_GROUP_CACHE_VERSION = 1; // A pause between consecutive changes longer than this starts a new group: // bursts of continuous editing read as a single row, however long they run, -// and any minute-plus lull splits the timeline. Baked into the cache doc — -// changing it invalidates and rebuilds every cache. -export const INACTIVITY_GAP_MS = 60 * 1000; +// and any ten-minute-plus lull splits the timeline. Baked into the cache +// doc — changing it invalidates and rebuilds every cache. +export const INACTIVITY_GAP_MS = 10 * 60 * 1000; // How long a fill may hold the main thread before yielding to idle time. const SLICE_BUDGET_MS = 8; diff --git a/drafts/src/styles.css b/drafts/src/styles.css index 884a2fa0..9b1aeb6c 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -130,9 +130,10 @@ box-shadow var(--studio-transition, 0.15s ease); } +/* Selection reads as a soft accent-tinted border, no extra ring — the open + changes list already sets the selected card apart. */ .draft-card[data-selected] { - border-color: var(--drafts-primary-line); - box-shadow: 0 0 0 1px var(--drafts-primary-line); + border-color: color-mix(in oklch, var(--drafts-primary) 35%, var(--drafts-border)); } .draft-card-header { @@ -159,7 +160,7 @@ } .draft-card[data-selected] .draft-card-header { - background: var(--drafts-selected-bg); + background: color-mix(in oklch, var(--drafts-selected-bg) 60%, transparent); } .draft-card-title { @@ -253,7 +254,7 @@ cursor: pointer; } -.draft-card-eye:hover:not(:disabled) { +.draft-card-eye:hover:not([data-disabled]) { background: var(--drafts-hover-bg); color: var(--drafts-primary-text); } @@ -262,7 +263,9 @@ color: var(--drafts-primary-text); } -.draft-card-eye:disabled { +/* Kept hoverable while disabled (aria-disabled, not the attribute) so the + explanatory tooltip shows; clicks are ignored in the handler. */ +.draft-card-eye[data-disabled] { opacity: 0.35; cursor: default; } @@ -363,11 +366,6 @@ background: color-mix(in oklch, var(--drafts-hover-bg) 60%, transparent); } -.draft-group-row[data-selected] { - background: color-mix(in oklch, var(--drafts-selected-bg) 60%, transparent); - box-shadow: inset 0 0 0 1px var(--drafts-primary); -} - .draft-group-spacer { flex: 1 1 auto; } @@ -413,6 +411,10 @@ to this box, and isolated so the indicator's own stacking context stays contained to this card. */ .draft-changes-body { + /* One softened ink for every scrubber mark (head, baseline, connector, + sticker border): mixed toward the background so the lines read as + annotations, not hard rules. Defined here so all of them share it. */ + --scrub-color: color-mix(in oklch, var(--drafts-fg) 55%, var(--drafts-bg)); position: relative; isolation: isolate; display: flex; @@ -443,7 +445,6 @@ in the foreground color. The box itself ignores pointer events so rows stay clickable; the handles re-enable them. */ .draft-scrubber-token { - --scrub-color: var(--drafts-fg); position: absolute; left: 0; right: 0; @@ -480,6 +481,12 @@ background: var(--scrub-color); } +/* The baseline's line reads fainter than the head's, so the version being + viewed stays the dominant mark. */ +.draft-scrubber-token--baseline .draft-scrubber-line { + opacity: 0.55; +} + /* Invisible grab strip over the line's gutter end; dragging only starts from this (or the dot), never from the rows or the bare gutter. */ .draft-scrubber-edge { @@ -493,6 +500,40 @@ touch-action: none; } +/* The diff baseline handle: a hollow circle (vs the head's solid dot) in the + gutter, sharing the head's line style across the rows. Only rendered with + the eye open and a version pinned. */ +.draft-scrubber-baseline { + position: absolute; + top: -5px; + left: 3px; + width: 10px; + height: 10px; + box-sizing: border-box; + border: 2px solid var(--scrub-color); + border-radius: 50%; + background: var(--studio-fill, white); + pointer-events: auto; + cursor: grab; + touch-action: none; +} + +.draft-scrubber-baseline:active { + cursor: grabbing; +} + +/* Joins the head and baseline handles down the gutter so the two read as one + diff range. Above the rows, below the handles. */ +.draft-scrubber-connector { + position: absolute; + left: 7px; + width: 2px; + z-index: 10; + background: var(--scrub-color); + opacity: 0.5; + pointer-events: none; +} + /* While pinned, a chip anchored to the head line overlays the group row's time/summary with the exact change the line sits on. Painted above everything in the changes body (opaque, topmost z-index) so neither the From 9b1b22bd321e26e404875bf2f1e6d0ec05caa98e Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Fri, 24 Jul 2026 17:04:33 +0200 Subject: [PATCH 10/18] bump drafts to 0.1.2 so the preview picks up the new package build --- drafts/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drafts/package.json b/drafts/package.json index 2a5a4366..6a8467be 100644 --- a/drafts/package.json +++ b/drafts/package.json @@ -1,6 +1,6 @@ { "name": "@tiny-patchwork/drafts", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "private": true, "main": "./dist/index.js", From cc523f38860646d0f6de41f0efb1e7b1eb4b2402 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Mon, 27 Jul 2026 10:20:39 +0200 Subject: [PATCH 11/18] add author attribution to draft timelines: map actor ids to contact avatars --- contact/src/components/InlineContactAvatar.ts | 1 + drafts/src/DraftsSidebar.tsx | 99 ++++++++++++-- drafts/src/actor-attribution.ts | 122 ++++++++++++++++++ drafts/src/change-group-cache.ts | 26 +++- drafts/src/draft-types.ts | 20 +++ drafts/src/index.ts | 1 + drafts/src/providers/DraftListProvider.ts | 30 ++++- drafts/src/styles.css | 14 ++ 8 files changed, 299 insertions(+), 14 deletions(-) create mode 100644 drafts/src/actor-attribution.ts diff --git a/contact/src/components/InlineContactAvatar.ts b/contact/src/components/InlineContactAvatar.ts index 5bb4d98a..f725b7cc 100644 --- a/contact/src/components/InlineContactAvatar.ts +++ b/contact/src/components/InlineContactAvatar.ts @@ -21,6 +21,7 @@ export function renderInlineContactAvatar( const isRegistered = contact.type === "registered"; const name = isRegistered ? contact.name : "Anonymous"; + avatar.title = name; // avatar image let avatarImgUrl: string | undefined; diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 3fe291b3..0118f88f 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -23,6 +23,7 @@ import { subscribeDoc, } from "@inkandswitch/patchwork-providers-solid"; import type { + ActorAttributionDoc, CachedGroup, ChangeGroupCacheDoc, CheckedOutDraft, @@ -50,10 +51,11 @@ const EMPTY_DRAFT_LIST: DraftList = { changeGroupCacheUrl: null, }, drafts: [], + actorAttributionUrl: null, }; // Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.28"; +const DRAFTS_VERSION = "0.0.31"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -124,6 +126,37 @@ export function DraftsSidebar(props: { element: HTMLElement }) { EMPTY_DRAFT_LIST ); + // Feed the module-level attribution store from this host doc's attribution + // doc, so `AuthorAvatars` can render contacts instead of raw actor ids. + createEffect(() => { + const url = list().actorAttributionUrl; + const repo = getRepo(); + if (!url || !repo) return; + let disposed = false; + let off: (() => void) | null = null; + void repo.find(url).then( + (handle) => { + if (disposed) return; + const update = () => { + const actors = handle.doc()?.actors; + if (actors && Object.keys(actors).length > 0) { + setActorContacts((prev) => ({ ...prev, ...actors })); + } + }; + handle.on("change", update); + off = () => handle.off("change", update); + update(); + }, + (err) => { + console.warn("[drafts] failed to load actor attribution:", url, err); + } + ); + onCleanup(() => { + disposed = true; + off?.(); + }); + }); + const isMainSelected = createMemo(() => selected() === null); // Drafting off a folder isn't supported yet, so creating a draft is disabled // while viewing a folder on Main. @@ -1904,24 +1937,41 @@ function TimeGroupRow(props: { ); } -// A stack of author avatars (deduped), newest-contributor first. +// A stack of author avatars, newest-contributor first. Actors with a known +// contact embed the contact tool's own avatar view (image or name initials — +// see contact/src/components/InlineContactAvatar.ts) and are deduped by +// contact — one person editing across several sessions reads as one avatar; +// unattributed actors fall back to actor-id rendering. function AuthorAvatars(props: { actors: string[] }) { - const visible = () => props.actors.slice(0, 3); - const extra = () => Math.max(0, props.actors.length - 3); + const authors = createMemo(() => resolveAuthors(props.actors)); + const visible = () => authors().slice(0, 3); + const extra = () => Math.max(0, authors().length - 3); return (
- {(actor, i) => ( + {(author, i) => (
- {getInitials(actor)} + + +
)}
@@ -2069,6 +2119,39 @@ function shortUrl(url: AutomergeUrl): string { return `${id.slice(0, 6)}…${id.slice(-4)}`; } +// ---- Author attribution ---------------------------------------------------- +// Module-level, like the `nowMs` ticker below: attribution (actor id -> +// contact url) is globally true, not per host doc, so one merged store can +// serve every card and survive doc switches. Fed by the sidebar's +// attribution-doc subscription. Rendering (including the name tooltip) is +// delegated to the contact tool (`tool-id="contact-inline"`). + +const [actorContacts, setActorContacts] = createSignal< + Record +>({}); + +// What an avatar renders for one author. `key` dedupes (and seeds the +// fallback rendering): the contact url when attributed, the actor id +// otherwise. +type AuthorDisplay = { + key: string; + contactUrl: AutomergeUrl | null; +}; + +function resolveAuthors(actors: string[]): AuthorDisplay[] { + const attribution = actorContacts(); + const out: AuthorDisplay[] = []; + const seen = new Set(); + for (const actor of actors) { + const contactUrl = attribution[actor] ?? null; + const key = contactUrl ?? actor; + if (seen.has(key)) continue; + seen.add(key); + out.push({ key, contactUrl }); + } + return out; +} + // A stable-ish color for an author, so the same person reads the same across // rows. Actors are Automerge actor ids (per device/session), the best "who" // signal available in a draft's raw change history. diff --git a/drafts/src/actor-attribution.ts b/drafts/src/actor-attribution.ts new file mode 100644 index 00000000..bec388b1 --- /dev/null +++ b/drafts/src/actor-attribution.ts @@ -0,0 +1,122 @@ +import { + isValidAutomergeUrl, + type AutomergeUrl, + type DocHandle, + type Repo, +} from "@automerge/automerge-repo"; +import * as Automerge from "@automerge/automerge"; +import { subscribe } from "@inkandswitch/patchwork-providers"; + +import type { ActorAttributionDoc, DraftDoc } from "./draft-types.js"; + +const CONTACT_SELECTOR = "patchwork:contact"; + +// The write side of author attribution. Only the writing client knows which +// Automerge actor ids are its own (each doc instance gets a fresh one per +// session), so attribution can't be derived after the fact: every local +// change reveals one id, and the recorder stamps it into the host doc's +// shared ActorAttributionDoc as actorId -> the current user's contact url. +export type ActorRecorder = { + // Feed from the change-group filler: `doc` just received a LOCAL change, + // so its actor id belongs to the current user. + onLocalChange: (doc: Automerge.Doc) => void; + // Point the recorder at the host doc's attribution doc once resolved. + setAttributionHandle: (handle: DocHandle) => void; + dispose: () => void; +}; + +// The current user's contact url comes from the `patchwork:contact` provider +// (the AccountProvider above the document area). Actor ids seen before it — +// or the attribution doc — resolves are buffered, not lost. If no provider +// ever answers, the mapping simply isn't written and authors fall back to +// raw actor rendering. +export function createActorRecorder(element: HTMLElement): ActorRecorder { + let contactUrl: AutomergeUrl | null = null; + let attributionHandle: DocHandle | null = null; + // Actor ids already written this session (skip cheaply) vs seen while the + // contact url or attribution doc was still resolving. + const recorded = new Set(); + const pending = new Set(); + let disposed = false; + + const unsubscribe = subscribe( + element, + { type: CONTACT_SELECTOR }, + (value) => { + if (disposed || contactUrl) return; + if (typeof value === "string" && isValidAutomergeUrl(value)) { + contactUrl = value; + flush(); + } + } + ); + + return { + onLocalChange(doc) { + if (disposed) return; + let actorId: string; + try { + actorId = Automerge.getActorId(doc); + } catch { + return; + } + if (recorded.has(actorId)) return; + pending.add(actorId); + flush(); + }, + setAttributionHandle(handle) { + if (disposed) return; + attributionHandle = handle; + flush(); + }, + dispose() { + disposed = true; + pending.clear(); + unsubscribe(); + }, + }; + + function flush(): void { + if (!attributionHandle || !contactUrl || pending.size === 0) return; + const url = contactUrl; + const actorIds = [...pending]; + pending.clear(); + for (const id of actorIds) recorded.add(id); + const existing = attributionHandle.doc()?.actors ?? {}; + const missing = actorIds.filter((id) => existing[id] !== url); + if (missing.length === 0) return; + attributionHandle.change((d) => { + for (const id of missing) d.actors[id] = url; + }); + } +} + +// Resolve the host doc's actor-attribution doc, creating it and stamping +// `actorAttributionUrl` on the main draft the first time. One per host doc +// (actor ids span main and every draft), following the same check-then-create +// pattern as `ensureChangeGroupCache`: the rare concurrent-create orphan is +// accepted. +export async function ensureActorAttribution( + repo: Repo, + mainDraftHandle: DocHandle +): Promise> { + const existingUrl = mainDraftHandle.doc()?.actorAttributionUrl; + if (existingUrl && isValidAutomergeUrl(existingUrl)) { + return repo.find(existingUrl); + } + + const attribution = repo.create({ + "@patchwork": { type: "actor-attribution" }, + actors: {}, + }); + mainDraftHandle.change((d) => { + if (!d.actorAttributionUrl) d.actorAttributionUrl = attribution.url; + }); + // A concurrent creator may have won the stamp; honor whichever pointer + // settled (our fresh doc is then an accepted orphan, same as the cache doc). + const settled = mainDraftHandle.doc()?.actorAttributionUrl; + if (settled && settled !== attribution.url && isValidAutomergeUrl(settled)) { + return repo.find(settled); + } + return attribution; +} diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts index f07bdc40..bb988218 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -4,6 +4,7 @@ import { isValidAutomergeUrl, type AutomergeUrl, type DocHandle, + type DocHandleChangePayload, type Repo, type UrlHeads, } from "@automerge/automerge-repo"; @@ -32,6 +33,10 @@ const SLICE_BUDGET_MS = 8; // Coalesce bursts of member-doc change events into one incremental fill. const FILL_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 filler 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 @@ -50,6 +55,13 @@ export type ChangeGroupCacheFiller = { dispose: () => void; }; +export type ChangeGroupCacheFillerOpts = { + // 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 + // actor-attribution recorder (see actor-attribution.ts). + onLocalChange?: (doc: Automerge.Doc) => void; +}; + // Resolve the host doc's single main draft, creating it (and pointing // `@patchwork.mainDraftUrl` at it) the first time. The main draft is // bookkeeping only: the list provider seeds its identity `clones`, and its @@ -360,14 +372,15 @@ function sameHeads(a: UrlHeads | undefined, b: UrlHeads | undefined): boolean { // history backfilling behind them. One global runner processes timelines // sequentially in the priority order `sync` was given. export function createChangeGroupCacheFiller( - repo: Repo + repo: Repo, + opts: ChangeGroupCacheFillerOpts = {} ): ChangeGroupCacheFiller { // 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: () => void; + onChange: (payload: DocHandleChangePayload) => void; }; type Task = { @@ -456,7 +469,14 @@ export function createChangeGroupCacheFiller( } for (const url of wanted) { if (task.listeners.has(url)) continue; - const onChange = () => { + const onChange = (payload: DocHandleChangePayload) => { + if ( + opts.onLocalChange && + LOCAL_PATCH_SOURCES.has(payload.patchInfo.source) && + payload.doc + ) { + opts.onLocalChange(payload.doc); + } if (task.debounce) clearTimeout(task.debounce); task.debounce = setTimeout(() => { task.debounce = null; diff --git a/drafts/src/draft-types.ts b/drafts/src/draft-types.ts index 9b7c1a16..e28f5623 100644 --- a/drafts/src/draft-types.ts +++ b/drafts/src/draft-types.ts @@ -38,6 +38,22 @@ export type DraftDoc = { // activity groups for its timeline. Stamped lazily by the fill engine the // first time it touches the timeline (see change-group-cache.ts). changeGroupCacheUrl?: AutomergeUrl; + // Main draft only: points at the host doc's ActorAttributionDoc, mapping + // actor ids to contact urls for author display. Stamped by the list + // provider alongside the eager main-draft creation. + actorAttributionUrl?: AutomergeUrl; +}; + +// Maps Automerge actor ids to the contact doc of the user who wrote with +// them. An actor id is per doc instance per session, so one person +// accumulates many — the map is many-to-one and only the writing client can +// attribute its own ids, which it does as it makes local changes (see +// actor-attribution.ts). One doc per host doc (actor ids span main and every +// draft), stamped on the main draft via `actorAttributionUrl`. Keyed by +// actor id so concurrent writers converge per-entry. +export type ActorAttributionDoc = { + "@patchwork": { type: "actor-attribution" }; + actors: Record; }; // One cached burst of activity in a draft's timeline: consecutive changes @@ -163,6 +179,10 @@ export type DraftSummary = { export type DraftList = { main: DraftSummary; drafts: DraftSummary[]; + // The host doc's ActorAttributionDoc url (`mainDraft.actorAttributionUrl`); + // `null` until stamped. The sidebar resolves timeline actors to contacts + // through it. + actorAttributionUrl: AutomergeUrl | null; }; // Convention: a document that has been drafted carries `@patchwork.mainDraftUrl` diff --git a/drafts/src/index.ts b/drafts/src/index.ts index 0d617ec9..22191e8c 100644 --- a/drafts/src/index.ts +++ b/drafts/src/index.ts @@ -60,6 +60,7 @@ export const plugins: Plugin[] = [ ]; export type { + ActorAttributionDoc, Baseline, CachedGroup, ChangeGroupCacheDoc, diff --git a/drafts/src/providers/DraftListProvider.ts b/drafts/src/providers/DraftListProvider.ts index 8b96e4a8..6c856331 100644 --- a/drafts/src/providers/DraftListProvider.ts +++ b/drafts/src/providers/DraftListProvider.ts @@ -28,6 +28,10 @@ import { ensureMainDraft, type TimelineSpec, } from "../change-group-cache.js"; +import { + createActorRecorder, + ensureActorAttribution, +} from "../actor-attribution.js"; const ROOT_DOC_SELECTOR = "draft:root-doc"; const CHECKED_OUT_SELECTOR = "draft:checked-out"; @@ -110,12 +114,20 @@ export const DraftListProvider = (element: HTMLElement) => { changeGroupCacheUrl: null, }, drafts: [], + actorAttributionUrl: null, }; + // Author attribution: stamps the local user's actor ids (revealed by local + // changes on the member docs the filler already watches) into the host + // doc's shared ActorAttributionDoc, so timelines can show contacts instead + // of raw actor ids. + const recorder = createActorRecorder(element); // The write side of the change-group cache: keeps every timeline's // ChangeGroupCacheDoc filled in background slices, whether or not the // sidebar is open. Fed the current timelines after each list recompute; // its own member-doc listeners drive incremental fills between recomputes. - const filler = createChangeGroupCacheFiller(repo); + const filler = createChangeGroupCacheFiller(repo, { + onLocalChange: recorder.onLocalChange, + }); // 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 @@ -176,8 +188,13 @@ export const DraftListProvider = (element: HTMLElement) => { const hostType = handle.doc()?.["@patchwork"]?.type; if (hostType == null || !SKIPPED_DATATYPES.has(hostType)) { try { - await ensureMainDraft(repo, handle); + 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 recorder starts stamping local actor ids. + const attribution = await ensureActorAttribution(repo, mainDraft); if (disposed) return; + recorder.setAttributionHandle(attribution); } catch (err) { console.warn("[drafts] failed to eagerly create main draft:", err); } @@ -269,6 +286,7 @@ export const DraftListProvider = (element: HTMLElement) => { return () => { disposed = true; filler.dispose(); + recorder.dispose(); element.removeEventListener("patchwork:subscribe", onSubscribe); element.removeEventListener("patchwork:mounted", onMounted); element.removeEventListener("patchwork:unmounted", onUnmounted); @@ -399,7 +417,12 @@ export const DraftListProvider = (element: HTMLElement) => { changeGroupCacheUrl: doc.changeGroupCacheUrl ?? null, }); } - return { main: computeMainSummary(), drafts }; + return { + main: computeMainSummary(), + drafts, + actorAttributionUrl: + mainDraftHandle?.doc()?.actorAttributionUrl ?? null, + }; } // Main's summary. Its members come from the main draft's identity clones once @@ -556,6 +579,7 @@ function clonesToMembers( } function draftListsEqual(a: DraftList, b: DraftList): boolean { + if (a.actorAttributionUrl !== b.actorAttributionUrl) return false; if (!summariesEqual(a.main, b.main)) return false; if (a.drafts.length !== b.drafts.length) return false; for (let i = 0; i < a.drafts.length; i++) { diff --git a/drafts/src/styles.css b/drafts/src/styles.css index 9b1aeb6c..917724ba 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -403,6 +403,20 @@ background: var(--drafts-faint-fg); } +/* An attributed author embeds the contact tool's avatar (a , see AuthorAvatars); size it to the stack slot — + the wrapper keeps the stack geometry (overlap + z-index). */ +.draft-avatar > patchwork-view { + display: flex; + width: 100%; + height: 100%; +} + +.draft-avatar .contact-avatar { + width: 100%; + height: 100%; +} + /* ---- Scrubber: the history gutter left of the group rows ---- */ /* Two-column body inside the scrollable changes area: gutter + group rows, From 5a82fa376e3f1529bc99273ce3078db0eb99341d Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Mon, 27 Jul 2026 13:32:53 +0200 Subject: [PATCH 12/18] rework draft actions into a per-card menu: fork, fork from version, merge up, delete Drag-to-fork and the New draft button are gone; a selected card's "..." menu now forks at the latest heads or at the scrubbed version, merges a draft up into the draft it was forked off (hover highlights the target card), and deletes a draft after a confirm dialog. Drafts track their parent, list depth-first, and render indented beneath Main. Merged drafts hide from the list; the scrubber sticker drops the doc title. --- drafts/src/DraftsSidebar.tsx | 787 +++++++++++++++------- drafts/src/draft-types.ts | 5 + drafts/src/providers/DraftListProvider.ts | 14 +- drafts/src/styles.css | 240 ++++--- 4 files changed, 689 insertions(+), 357 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 0118f88f..fc594a08 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -17,7 +17,6 @@ import type { } from "@automerge/automerge-repo"; import { decodeHeads, encodeHeads } from "@automerge/automerge-repo"; import * as Automerge from "@automerge/automerge"; -import { getRegistry, isLoadedPlugin } from "@inkandswitch/patchwork-plugins"; import { subscribe, subscribeDoc, @@ -32,6 +31,7 @@ import type { DraftDoc, DraftList, DraftMemberDoc, + DraftSummary, HasDrafts, } from "./draft-types"; import { @@ -45,6 +45,7 @@ import { const EMPTY_DRAFT_LIST: DraftList = { main: { url: "" as AutomergeUrl, + parent: null, members: [], childCount: 0, name: null, @@ -55,7 +56,7 @@ const EMPTY_DRAFT_LIST: DraftList = { }; // Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.31"; +const DRAFTS_VERSION = "0.0.39"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -109,15 +110,6 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // version is pinned; that is also exactly when the handle renders. const [baseliner, setBaseliner] = createSignal(null); - // A version being dragged out of a history timeline (from a group row or - // the scrubber sticker). While set, the actions area shows a drop zone - // that forks a new draft at that version; cleared on drop or dragend. - const [dragVersion, setDragVersion] = createSignal<{ - members: DraftMemberDoc[]; - head: ChangeRef; - } | null>(null); - const [dropActive, setDropActive] = createSignal(false); - // The derived drafts list (read-only): main plus each draft with its member // docs, recomputed and pushed by the provider. const list = subscribe( @@ -378,42 +370,19 @@ export function DraftsSidebar(props: { element: HTMLElement }) { }); }); - const onCreateDraft = async () => { - if (isFolder()) return; - const docHandle = hostDocHandle(); - if (!docHandle) return; - const repo = getRepo(); - if (!repo) { - console.warn("[drafts] window.repo is not set"); - return; - } - - // Top-level drafts branch off the main draft and live in its `drafts` - // list. The main draft is created lazily the first time we draft this doc. - const mainDraft = await ensureMainDraft(repo, docHandle); - const draft = repo.create({ - "@patchwork": { type: "draft" }, - parent: mainDraft.url, - drafts: [], - clones: {}, - }); - mainDraft.change((d) => { - d.drafts.push(draft.url); - }); - selectDraft(draft.url); - }; - - // Fork a new top-level draft off a historical version: every member doc is - // cloned at the heads it had as of `head` (the dragged-out change), not at - // the live latest. Pre-populating `DraftDoc.clones` here means the overlay's - // lazy `resolveClone` reuses these entries instead of forking at current - // heads. Members with no changes at or before the version (created later) - // are left out; the version's docs don't reference them yet, so they are - // normally never resolved beneath the draft. - const onCreateDraftFromVersion = async ( - members: DraftMemberDoc[], - head: ChangeRef - ) => { + // Fork the current selection as a new child draft. `atVersion` picks the + // fork point (the two menu items): true clones every member doc at the + // heads it had as of the scrubbed version, false forks at the latest + // heads. The new draft is parented to the selection — merging it later + // lands back here, not on main. Pre-populating `DraftDoc.clones` means the + // overlay's lazy `resolveClone` reuses these entries instead of forking + // the originals at current heads (which matters when forking off a draft: + // the clones must branch off the draft's clones). Forking main live needs + // no eager clones — main's members are the originals, so the lazy path is + // exactly right. Members with no changes at or before a pinned version + // (created later) are left out; the version's docs don't reference them + // yet, so they are normally never resolved beneath the draft. + const onForkSelection = async (atVersion: boolean) => { if (isFolder()) return; const docHandle = hostDocHandle(); if (!docHandle) return; @@ -423,38 +392,61 @@ export function DraftsSidebar(props: { element: HTMLElement }) { return; } - // Reuse the scrub machinery to resolve per-doc heads at this version - // (only the `to`s are read, so no diff baseline). - const checkpoint = await computeCheckpoint(repo, members, head, "none"); + const parentUrl = selected(); // null = main + const members = membersFor(parentUrl); + const head = atVersion ? (scrubber()?.head ?? null) : null; const clones: Record = {}; - for (const member of members) { - const to = checkpoint[member.url]?.to; - if (!to) continue; - let handle: DocHandle | null = null; - try { - // Clone the doc the timeline read its changes from (the draft's clone - // when dragging out of a draft), pinned to the version's heads. - // Keyed by the original url so baselines and merge-back resolve. - handle = await repo.find(member.cloneUrl ?? member.url); - const clone = cloneAtVersion(repo, handle, to); - clones[member.url] = { cloneUrl: clone.url, clonedAt: to }; - } catch (err) { - reportForkFailure( - handle ? collectForkDiagnostic(handle, member, to) : null, - err - ); + if (head) { + // Reuse the scrub machinery to resolve per-doc heads at this version + // (only the `to`s are read, so no diff baseline). + const checkpoint = await computeCheckpoint(repo, members, head, "none"); + for (const member of members) { + const to = checkpoint[member.url]?.to; + if (!to) continue; + let handle: DocHandle | null = null; + try { + // Clone the doc the timeline read its changes from (the draft's + // clone when forking off a draft), pinned to the version's heads. + // Keyed by the original url so baselines and merge-back resolve. + handle = await repo.find(member.cloneUrl ?? member.url); + const clone = cloneAtVersion(repo, handle, to); + clones[member.url] = { cloneUrl: clone.url, clonedAt: to }; + } catch (err) { + reportForkFailure( + handle ? collectForkDiagnostic(handle, member, to) : null, + err + ); + } + } + } else if (parentUrl) { + // Forking a draft at its latest heads: branch each member off the + // draft's clone (not the original, which lacks the draft's changes). + for (const member of members) { + try { + const source = await repo.find( + member.cloneUrl ?? member.url + ); + const clonedAt = source.heads(); + const clone = repo.clone(source); + clones[member.url] = { cloneUrl: clone.url, clonedAt }; + } catch (err) { + console.warn("[drafts] failed to fork member:", member, err); + } } } const mainDraft = await ensureMainDraft(repo, docHandle); + const parentHandle = parentUrl + ? await repo.find(parentUrl) + : mainDraft; const draft = repo.create({ "@patchwork": { type: "draft" }, - parent: mainDraft.url, + parent: parentHandle.url, drafts: [], clones, }); - mainDraft.change((d) => { + parentHandle.change((d) => { d.drafts.push(draft.url); }); selectDraft(draft.url); @@ -480,10 +472,63 @@ export function DraftsSidebar(props: { element: HTMLElement }) { }); }; + // The selected draft's summary and parent, driving the merge button on the + // selected draft's header (it merges up into the parent). + const selectedSummary = createMemo( + () => list().drafts.find((s) => s.url === selected()) ?? null + ); + const mergeParentUrl = createMemo( + () => selectedSummary()?.parent ?? null + ); + // Display name of the merge target (the parent): the tooltip and the + // confirm dialog both name it. + const mergeTargetName = createMemo(() => { + const parentUrl = mergeParentUrl(); + if (parentUrl === null || parentUrl === list().main.url) { + return list().main.name ?? "Main"; + } + return list().drafts.find((s) => s.url === parentUrl)?.name ?? "Draft"; + }); + + // Label of the menu's fork-from-version item, e.g. "Fork from Jul 24, + // 3:12 PM" — the change the scrubber sits on. Null (item hidden) while + // the timeline isn't pinned. + const forkAtLabel = createMemo(() => { + if (!isPinned()) return null; + const time = scrubber()?.head.time; + if (!time) return null; + return `Fork from ${formatVersionTime(time)}`; + }); + + // True while the menu's merge item is hovered: the target card (the + // selected draft's parent) lights up via `data-merge-target`. + const [mergeHighlight, setMergeHighlight] = createSignal(false); + + // Nesting depth for a card's indentation: hops up the parent chain until + // main (a top-level draft's parent is the main draft, which isn't in the + // drafts list, so it counts 0). Cycle-guarded — parents are plain urls. + const draftDepth = (summary: DraftSummary): number => { + const byUrl = new Map(list().drafts.map((s) => [s.url, s])); + const seen = new Set(); + let depth = 0; + let parent = summary.parent; + while (parent && byUrl.has(parent) && !seen.has(parent)) { + seen.add(parent); + depth++; + parent = byUrl.get(parent)!.parent; + } + return depth; + }; + + // Merge the selected draft into its parent — the draft it was forked off, + // or main for a top-level draft — then check the parent out. The merged + // draft's `mergedAt` stamp hides it from the list. const onMergeDraft = async () => { const draftUrl = selected(); if (!draftUrl) return; - if (!window.confirm("Merge this draft into the main document?")) return; + const parentUrl = mergeParentUrl(); + if (!window.confirm(`Merge this draft into "${mergeTargetName()}"?`)) + return; const repo = getRepo(); if (!repo) { console.warn("[drafts] window.repo is not set"); @@ -491,7 +536,42 @@ export function DraftsSidebar(props: { element: HTMLElement }) { } const draftHandle = await repo.find(draftUrl); await mergeDraft(repo, draftHandle); - selectDraft(null); + selectDraft(parentUrl && parentUrl !== list().main.url ? parentUrl : null); + }; + + // Delete the selected draft: unlink it from its parent's `drafts` list, + // which drops it — along with any drafts forked from it — from every + // peer's tree walk. Nothing is merged and the docs themselves are left in + // place; the draft just becomes unreachable. + const onDeleteDraft = async () => { + const draftUrl = selected(); + if (!draftUrl) return; + const childCount = selectedSummary()?.childCount ?? 0; + const warning = + childCount > 0 + ? "Delete this draft? Drafts forked from it will be deleted too. This can't be undone." + : "Delete this draft? This can't be undone."; + if (!window.confirm(warning)) return; + const repo = getRepo(); + if (!repo) { + console.warn("[drafts] window.repo is not set"); + return; + } + const parentUrl = mergeParentUrl(); + let parentHandle: DocHandle; + if (parentUrl) { + parentHandle = await repo.find(parentUrl); + } else { + // Legacy drafts without a `parent` field hang off the main draft. + const docHandle = hostDocHandle(); + if (!docHandle) return; + parentHandle = await ensureMainDraft(repo, docHandle); + } + parentHandle.change((d) => { + const i = d.drafts.indexOf(draftUrl); + if (i >= 0) d.drafts.splice(i, 1); + }); + selectDraft(parentUrl && parentUrl !== list().main.url ? parentUrl : null); }; return ( @@ -500,27 +580,6 @@ export function DraftsSidebar(props: { element: HTMLElement }) { when={hostDoc()} fallback={
No document selected.
} > - -
- - - - Drafts aren't supported for folders yet. - - -
-
(isMainSelected() ? baseliner() : null)} - onDragVersion={(head) => - setDragVersion( - head ? { members: list().main.members, head } : null - ) - } hasCheckpoint={isMainSelected() && isPinned()} onReturnToLatest={clearCheckpoint} eyeOpen={isMainSelected() && eyeOpen()} eyeDisabled={!isPinned()} onToggleEye={toggleEye} + forkDisabled={isFolder()} + onFork={() => void onForkSelection(false)} + forkAtLabel={forkAtLabel()} + onForkAt={() => void onForkSelection(true)} + isMergeTarget={ + mergeHighlight() && mergeParentUrl() === list().main.url + } /> {(summary) => ( @@ -556,6 +617,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { mainDocUrl={hostDocHandle()?.url} isSelected={selected() === summary.url} name={summary.name} + depth={draftDepth(summary)} onRename={(name) => void onRename(summary.url, name)} onSelect={selectDraft} onScrub={(scrub) => @@ -570,81 +632,69 @@ export function DraftsSidebar(props: { element: HTMLElement }) { baseliner={() => selected() === summary.url ? baseliner() : null } - onDragVersion={(head) => - setDragVersion( - head ? { members: summary.members, head } : null - ) - } hasCheckpoint={selected() === summary.url && isPinned()} onReturnToLatest={clearCheckpoint} eyeOpen={selected() === summary.url && eyeOpen()} eyeDisabled={false} onToggleEye={toggleEye} + onFork={() => void onForkSelection(false)} + forkAtLabel={forkAtLabel()} + onForkAt={() => void onForkSelection(true)} + mergeLabel={`Merge into "${mergeTargetName()}"`} + onMerge={() => void onMergeDraft()} + onMergeHover={setMergeHighlight} + onDelete={() => void onDeleteDraft()} + isMergeTarget={ + mergeHighlight() && mergeParentUrl() === summary.url + } /> )}
-
- -
{ - e.preventDefault(); - setDropActive(true); - }} - onDragOver={(e) => { - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; - }} - onDragLeave={() => setDropActive(false)} - onDrop={(e) => { - e.preventDefault(); - const version = dragVersion(); - setDropActive(false); - setDragVersion(null); - if (version) { - void onCreateDraftFromVersion(version.members, version.head); - } - }} - > - Drop to fork a new draft from this version -
-
- - - -
v{DRAFTS_VERSION}
); } -// Merges every cloned doc back into its original, recording per-clone -// merge heads for auditing, and marks the draft as merged. +// Merges every cloned doc back into the parent draft's copy of it — the +// parent's clone when it has one, the original otherwise (the main draft's +// identity clones make those the same thing, so a top-level draft merges +// into the originals) — recording per-clone merge heads for auditing, and +// marks the draft as merged (which hides it from the list). async function mergeDraft( repo: Repo, draftHandle: DocHandle ): Promise { - const entries = Object.entries(draftHandle.doc()?.clones ?? {}) as [ + const doc = draftHandle.doc(); + let parentClones: Record = {}; + const parentUrl = doc?.parent; + if (parentUrl) { + try { + const parent = await repo.find(parentUrl); + parentClones = parent.doc()?.clones ?? {}; + } catch (err) { + console.warn( + "[drafts] failed to load parent draft for merge; " + + "falling back to merging into the originals:", + parentUrl, + err + ); + } + } + const entries = Object.entries(doc?.clones ?? {}) as [ AutomergeUrl, CloneEntry, ][]; for (const [originalUrl, entry] of entries) { - if (entry.cloneUrl === originalUrl) continue; - const [original, clone] = await Promise.all([ - repo.find(originalUrl), + const targetUrl = parentClones[originalUrl]?.cloneUrl ?? originalUrl; + if (entry.cloneUrl === targetUrl) continue; + const [target, clone] = await Promise.all([ + repo.find(targetUrl), repo.find(entry.cloneUrl), ]); - original.merge(clone); - const mergedAt = original.heads(); + target.merge(clone); + const mergedAt = target.heads(); draftHandle.change((d) => { const e = d.clones[originalUrl]; if (e) e.mergedAt = mergedAt; @@ -958,15 +1008,26 @@ function MainCard(props: { scrubber: Accessor; onBaselineScrub: (base: BaselineState) => void; baseliner: Accessor; - onDragVersion: (head: ChangeRef | null) => void; hasCheckpoint: boolean; onReturnToLatest: () => void; eyeOpen: boolean; eyeDisabled: boolean; onToggleEye: () => void; + forkDisabled: boolean; + onFork: () => void; + forkAtLabel: string | null; + onForkAt: () => void; + // True while the merge menu item is hovered and this card is the target. + isMergeTarget: boolean; }) { + const [menuOpen, setMenuOpen] = createSignal(false); return ( -
+
{/* A div, not a - + + + +
@@ -1016,7 +1086,6 @@ function MainCard(props: { onBaselineScrub={props.onBaselineScrub} baseliner={props.baseliner} eyeOpen={() => props.eyeOpen} - onDragVersion={props.onDragVersion} onReturnToLatest={props.onReturnToLatest} /> @@ -1031,21 +1100,46 @@ function DraftCard(props: { mainDocUrl: AutomergeUrl | undefined; isSelected: boolean; name: string | null; + // Nesting depth below main (0 = top-level draft); indents the card so a + // fork reads as a child of the card above it. Rendering adds one level so + // every draft — main's children included — sits indented under the Main + // card. + depth: number; onRename: (name: string | null) => void; onSelect: (url: AutomergeUrl) => void; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; onBaselineScrub: (base: BaselineState) => void; baseliner: Accessor; - onDragVersion: (head: ChangeRef | null) => void; hasCheckpoint: boolean; onReturnToLatest: () => void; eyeOpen: boolean; eyeDisabled: boolean; onToggleEye: () => void; + onFork: () => void; + forkAtLabel: string | null; + onForkAt: () => void; + // Menu item text naming the merge target (this draft's parent, e.g. + // `Merge into "Main"`); merging goes up. + mergeLabel: string; + onMerge: () => void; + // Fires with true/false as the merge item is hovered/left, so the parent + // card can light up as the target. + onMergeHover: (over: boolean) => void; + // True while the merge menu item is hovered and this card is the target. + isMergeTarget: boolean; + // Unlinks the draft (and its forks) from the tree, after a confirm dialog. + onDelete: () => void; }) { + const [menuOpen, setMenuOpen] = createSignal(false); return ( -
+
{/* A div, not a
@@ -1091,7 +1199,6 @@ function DraftCard(props: { onBaselineScrub={props.onBaselineScrub} baseliner={props.baseliner} eyeOpen={() => props.eyeOpen} - onDragVersion={props.onDragVersion} onReturnToLatest={props.onReturnToLatest} /> @@ -1099,6 +1206,259 @@ function DraftCard(props: { ); } +// The "⋯" context menu in a selected card's title. Items: fork at the +// latest heads, fork from the scrubbed version (only while the timeline is +// pinned), and — on draft cards — merge up into the parent. Hovering the +// merge item highlights the target card via `onHoverTarget`. The dropdown +// is anchored to the trigger; `onOpenChange` mirrors the open state up so +// the card can lift its overflow clipping while the list is showing. +function CardMenu(props: { + forkDisabled?: boolean; + onFork: () => void; + // "Fork from Jul 24, 3:12 PM" while pinned; null hides the item. + forkAtLabel: string | null; + onForkAt: () => void; + // Draft cards only: the merge-up item. + merge?: { + label: string; + onMerge: () => void; + onHoverTarget: (over: boolean) => void; + }; + // Draft cards only: the delete item (confirmed via a dialog in the + // handler). + onDelete?: () => void; + onOpenChange: (open: boolean) => void; +}) { + const [open, setOpenSignal] = createSignal(false); + const setOpen = (v: boolean) => { + setOpenSignal(v); + props.onOpenChange(v); + if (!v) props.merge?.onHoverTarget(false); + }; + // The menu unmounts with its card's selection; reset what was mirrored up. + onCleanup(() => setOpen(false)); + + createEffect(() => { + if (!open()) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKey); + onCleanup(() => document.removeEventListener("keydown", onKey)); + }); + + // Every item click: don't bubble into the header's select, close, run. + const pick = (e: MouseEvent, action: () => void) => { + e.stopPropagation(); + setOpen(false); + action(); + }; + + return ( + + + + {/* Invisible click-catcher behind the list: any click outside the + items closes the menu without doing anything else. */} +
{ + e.stopPropagation(); + setOpen(false); + }} + /> +
+ + + {(label) => ( + + )} + + + {(merge) => ( + <> +
+ + + )} + + + {(onDelete) => ( + <> +
+ + + )} + +
+
+ + ); +} + +// "Jul 24, 3:12 PM" (with the year when it isn't the current one), for the +// fork-from-version menu item. +function formatVersionTime(ms: number): string { + const date = new Date(ms); + const sameYear = date.getFullYear() === new Date().getFullYear(); + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: sameYear ? undefined : "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function GitBranchIcon() { + return ( + + + + + + + ); +} + +function GitMergeIcon() { + return ( + + + + + + ); +} + +function EllipsisIcon() { + return ( + + + + + + ); +} + +function TrashIcon() { + return ( + + + + + + + + ); +} + +// A clock rewinding: the fork-from-a-past-version item. +function HistoryIcon() { + return ( + + + + + + ); +} + // A card's display name. Double-click to rename inline: Enter or clicking // away commits, Escape cancels, and committing an empty value clears the // name back to the default label. @@ -1293,7 +1653,6 @@ function DraftChangesList(props: { onBaselineScrub: (base: BaselineState) => void; baseliner: Accessor; eyeOpen: Accessor; - onDragVersion: (head: ChangeRef | null) => void; onReturnToLatest: () => void; }) { const repo = "repo" in window ? window.repo : undefined; @@ -1735,16 +2094,6 @@ function DraftChangesList(props: { target.addEventListener("pointercancel", onUp); }; - // Start a native drag carrying a version out of the timeline. The payload - // rides in a component signal (source and drop zone share the panel); - // dataTransfer is only set so the browser actually starts the drag. - const beginVersionDrag = (ev: DragEvent, head: ChangeRef) => { - if (!ev.dataTransfer) return; - ev.dataTransfer.setData("text/plain", `${head.docUrl}#${head.hash}`); - ev.dataTransfer.effectAllowed = "copy"; - props.onDragVersion(head); - }; - // The exact change the scrubber head sits on, recovered through the // on-demand scan; feeds the sticker that overlays the group row with the // version being looked at. It is suppressed when the head sits exactly on @@ -1773,22 +2122,6 @@ function DraftChangesList(props: { return computeEditCounts(change.doc, change.hash, change.deps); }); - // The sticker's title, resolved lazily per member doc and cached. - const [titles, setTitles] = createSignal>({}); - createEffect(() => { - const change = headChange(); - if (!change) return; - if (titles()[change.docUrl] !== undefined) return; - void resolveDocTitle(change.doc, change.docUrl).then((title) => { - setTitles((t) => ({ ...t, [change.docUrl]: title })); - }); - }); - const headTitle = (): string => { - const change = headChange(); - if (!change) return ""; - return titles()[change.docUrl] ?? shortUrl(change.docUrl); - }; - return (
rowEls.set(group.id, el)} onSelect={() => selectGroup(group)} - onVersionDragStart={(e) => - beginVersionDrag(e, { - docUrl: group.newestMemberUrl, - hash: group.newestHash, - time: group.endTime, - }) - } - onVersionDragEnd={() => props.onDragVersion(null)} /> )} @@ -1835,27 +2160,17 @@ function DraftChangesList(props: { onPointerDown={beginDrag} /> {/* Pinned inside a group: overlay the row with the exact - version the head sits on. Draggable — dragging it out forks - a new draft at that version. */} + version the head sits on (the card's Fork button forks + from it). */} {(change) => (
- beginVersionDrag(e, { - docUrl: change().docUrl, - hash: change().hash, - time: change().time, - }) - } - onDragEnd={() => props.onDragVersion(null)} + title="The version being viewed — Fork below to branch from here" > {formatTime(change().time)} - {headTitle()} void; onSelect: () => void; - onVersionDragStart: (e: DragEvent) => void; - onVersionDragEnd: () => void; }) { return (
-
v{DRAFTS_VERSION}
); } @@ -1008,6 +1013,7 @@ function MainCard(props: { scrubber: Accessor; onBaselineScrub: (base: BaselineState) => void; baseliner: Accessor; + checkpoint: Accessor; hasCheckpoint: boolean; onReturnToLatest: () => void; eyeOpen: boolean; @@ -1086,6 +1092,7 @@ function MainCard(props: { onBaselineScrub={props.onBaselineScrub} baseliner={props.baseliner} eyeOpen={() => props.eyeOpen} + checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} /> @@ -1111,6 +1118,7 @@ function DraftCard(props: { scrubber: Accessor; onBaselineScrub: (base: BaselineState) => void; baseliner: Accessor; + checkpoint: Accessor; hasCheckpoint: boolean; onReturnToLatest: () => void; eyeOpen: boolean; @@ -1199,6 +1207,7 @@ function DraftCard(props: { onBaselineScrub={props.onBaselineScrub} baseliner={props.baseliner} eyeOpen={() => props.eyeOpen} + checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} /> @@ -1653,6 +1662,9 @@ function DraftChangesList(props: { onBaselineScrub: (base: BaselineState) => void; baseliner: Accessor; eyeOpen: Accessor; + // The live checkpoint (`CheckedOutDraft.at`): per-member `to`/`from` + // heads, read for the sticker's whole-range diff counts. + checkpoint: Accessor; onReturnToLatest: () => void; }) { const repo = "repo" in window ? window.repo : undefined; @@ -2114,11 +2126,38 @@ function DraftChangesList(props: { ); }); - // The sticker's per-change +/- counts: one on-demand diff of the single - // change under the head (the cache stores only group aggregates). + // The sticker's +/- counts. With the eye open they cover the whole diff + // range — each member diffed from the checkpoint's `from` (the baseline) + // to its `to` (the head), summed — so extending the baseline grows them. + // With the eye closed there is no range, so they fall back to the single + // change under the head. const headCounts = createMemo(() => { const change = headChange(); if (!change) return null; + if (props.eyeOpen()) { + const at = props.checkpoint(); + const srcs = sources(); + if (at && srcs) { + let additions = 0; + let deletions = 0; + let spans = 0; + for (const { member, handle } of srcs) { + const entry = at[member.url]; + if (!entry?.from || !entry.to) continue; + const doc = handle.doc() as Automerge.Doc | undefined; + if (!doc) continue; + spans++; + const counts = computeRangeEditCounts( + doc, + decodeHeads(entry.from), + decodeHeads(entry.to) + ); + additions += counts.additions; + deletions += counts.deletions; + } + if (spans > 0) return { additions, deletions }; + } + } return computeEditCounts(change.doc, change.hash, change.deps); }); diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts index bb988218..aefdc15b 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -149,30 +149,61 @@ export function computeEditCounts( hash: string, deps: string[] ): { additions: number; deletions: number } { - let additions = 0; - let deletions = 0; try { - const patches = Automerge.diff( - doc, - deps as unknown as Automerge.Heads, - [hash] as unknown as Automerge.Heads + return countPatches( + Automerge.diff( + doc, + deps as unknown as Automerge.Heads, + [hash] as unknown as Automerge.Heads + ) ); - for (const patch of patches) { - if (patch.path[0] === "@patchwork") continue; - if (patch.action === "splice") { - additions += (patch.value as string).length; - } else if (patch.action === "insert") { - additions += Array.isArray((patch as { values?: unknown[] }).values) - ? (patch as { values: unknown[] }).values.length - : 1; - } else if (patch.action === "del") { - deletions += (patch as { length?: number }).length ?? 1; - } else { - additions += 1; - } - } } catch (err) { console.warn("[drafts] failed to diff change for edit counts:", hash, err); + return { additions: 0, deletions: 0 }; + } +} + +// The same +/- magnitude over an arbitrary head range (`from` → `to`), e.g. +// the eye's diff span between the baseline and the scrubbed head. +export function computeRangeEditCounts( + doc: Automerge.Doc, + from: string[], + to: string[] +): { additions: number; deletions: number } { + try { + return countPatches( + Automerge.diff( + doc, + from as unknown as Automerge.Heads, + to as unknown as Automerge.Heads + ) + ); + } catch (err) { + console.warn("[drafts] failed to diff range for edit counts:", err); + return { additions: 0, deletions: 0 }; + } +} + +// Shared patch-counting rules for the two diff flavors above. +function countPatches(patches: Automerge.Patch[]): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + for (const patch of patches) { + if (patch.path[0] === "@patchwork") continue; + if (patch.action === "splice") { + additions += (patch.value as string).length; + } else if (patch.action === "insert") { + additions += Array.isArray((patch as { values?: unknown[] }).values) + ? (patch as { values: unknown[] }).values.length + : 1; + } else if (patch.action === "del") { + deletions += (patch as { length?: number }).length ?? 1; + } else { + additions += 1; + } } return { additions, deletions }; } diff --git a/drafts/src/styles.css b/drafts/src/styles.css index 09327022..e443a2d7 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -110,10 +110,6 @@ background: var(--drafts-hover-bg); } -.draft-card[data-selected] .draft-card-header { - background: color-mix(in oklch, var(--drafts-selected-bg) 60%, transparent); -} - .draft-card-title { display: flex; align-items: center; @@ -159,7 +155,10 @@ flex-direction: column; gap: 0.125rem; border-top: 1px solid var(--drafts-border); - padding: 0.25rem; + /* Extra top headroom: the scrubber's dot (5px) and sticker (10px) overhang + above the head line, which sits at 0 on the latest version — without + this they'd crowd into the card header. */ + padding: 0.75rem 0.25rem 0.25rem; max-height: 12rem; overflow-y: auto; } @@ -671,10 +670,3 @@ color: var(--drafts-deleted); } -.drafts-version { - padding: 0.25rem 0.5rem; - font-family: var(--drafts-mono); - font-size: 0.625rem; - color: var(--drafts-faint-fg); - text-align: right; -} From b296b06ff37dd52d0dd7d2949c89947e9b332132 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 08:49:35 +0200 Subject: [PATCH 14/18] merged drafts hand their children up to the merge target; new forks get stable "Draft N" names --- drafts/src/DraftsSidebar.tsx | 98 ++++++++++++++++++----- drafts/src/draft-types.ts | 3 + drafts/src/providers/DraftListProvider.ts | 21 ++++- 3 files changed, 103 insertions(+), 19 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 4d4b22c4..a87da6e1 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -15,7 +15,11 @@ import type { Repo, UrlHeads, } from "@automerge/automerge-repo"; -import { decodeHeads, encodeHeads } from "@automerge/automerge-repo"; +import { + decodeHeads, + encodeHeads, + isValidAutomergeUrl, +} from "@automerge/automerge-repo"; import * as Automerge from "@automerge/automerge"; import { subscribe, @@ -58,7 +62,7 @@ const EMPTY_DRAFT_LIST: DraftList = { // Logged on load and stamped into fork diagnostics; bump on deploy to tell // builds apart (no longer shown in the UI). -const DRAFTS_VERSION = "0.0.42"; +const DRAFTS_VERSION = "0.0.43"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -442,8 +446,16 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const parentHandle = parentUrl ? await repo.find(parentUrl) : mainDraft; + // Default name from a monotonic counter on the main draft, stamped at + // creation so numbering stays stable as drafts are merged or deleted. + let draftNumber = 1; + mainDraft.change((d) => { + d.draftCounter = (d.draftCounter ?? 0) + 1; + draftNumber = d.draftCounter; + }); const draft = repo.create({ "@patchwork": { type: "draft" }, + name: `Draft ${draftNumber}`, parent: parentHandle.url, drafts: [], clones, @@ -666,27 +678,16 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // parent's clone when it has one, the original otherwise (the main draft's // identity clones make those the same thing, so a top-level draft merges // into the originals) — recording per-clone merge heads for auditing, and -// marks the draft as merged (which hides it from the list). +// marks the draft as merged (which hides it from the list). The merged +// draft's children are handed up to the merge target, so they never dangle +// under a hidden draft. async function mergeDraft( repo: Repo, draftHandle: DocHandle ): Promise { const doc = draftHandle.doc(); - let parentClones: Record = {}; - const parentUrl = doc?.parent; - if (parentUrl) { - try { - const parent = await repo.find(parentUrl); - parentClones = parent.doc()?.clones ?? {}; - } catch (err) { - console.warn( - "[drafts] failed to load parent draft for merge; " + - "falling back to merging into the originals:", - parentUrl, - err - ); - } - } + const parentHandle = await findMergeTarget(repo, doc?.parent); + const parentClones = parentHandle?.doc()?.clones ?? {}; const entries = Object.entries(doc?.clones ?? {}) as [ AutomergeUrl, CloneEntry, @@ -708,6 +709,67 @@ async function mergeDraft( draftHandle.change((d) => { d.mergedAt = Date.now(); }); + + // Re-parent the merged draft's children onto the merge target: they list + // under it and their own merges land there (where this draft's changes + // now live). Their clones branched off this draft's clones, whose history + // was just merged into the target, so they still merge back cleanly. + if (parentHandle) { + const children = (draftHandle.doc()?.drafts ?? []).filter( + isValidAutomergeUrl + ); + for (const childUrl of children) { + try { + const child = await repo.find(childUrl); + child.change((d) => { + d.parent = parentHandle.url; + }); + parentHandle.change((d) => { + if (!d.drafts.includes(childUrl)) d.drafts.push(childUrl); + }); + draftHandle.change((d) => { + const i = d.drafts.indexOf(childUrl); + if (i >= 0) d.drafts.splice(i, 1); + }); + } catch (err) { + console.warn( + "[drafts] failed to re-parent child draft after merge:", + childUrl, + err + ); + } + } + } +} + +// Resolve the draft the merge should land in: the nearest non-merged +// ancestor. A parent that was itself merged away hands its role up the +// chain (its changes live in *its* merge target), ending at the main draft, +// which is never merged. Null when the chain can't be resolved — the caller +// then falls back to merging into the originals. +async function findMergeTarget( + repo: Repo, + parentUrl: AutomergeUrl | undefined +): Promise | null> { + const seen = new Set(); + let cursor = parentUrl; + while (cursor && isValidAutomergeUrl(cursor) && !seen.has(cursor)) { + seen.add(cursor); + try { + const candidate = await repo.find(cursor); + if (candidate.doc()?.mergedAt === undefined) return candidate; + cursor = candidate.doc()?.parent; + } catch (err) { + console.warn( + "[drafts] failed to load ancestor draft for merge; " + + "falling back to merging into the originals:", + cursor, + err + ); + return null; + } + } + return null; } // --- Cloning a member at a version ------------------------------------------- diff --git a/drafts/src/draft-types.ts b/drafts/src/draft-types.ts index dc1d0e09..87ddea47 100644 --- a/drafts/src/draft-types.ts +++ b/drafts/src/draft-types.ts @@ -42,6 +42,9 @@ export type DraftDoc = { // actor ids to contact urls for author display. Stamped by the list // provider alongside the eager main-draft creation. actorAttributionUrl?: AutomergeUrl; + // Main draft only: monotonic counter behind new forks' default names + // ("Draft N"). Never decremented, so numbers aren't reused after deletes. + draftCounter?: number; }; // Maps Automerge actor ids to the contact doc of the user who wrote with diff --git a/drafts/src/providers/DraftListProvider.ts b/drafts/src/providers/DraftListProvider.ts index a6bff642..feea5c81 100644 --- a/drafts/src/providers/DraftListProvider.ts +++ b/drafts/src/providers/DraftListProvider.ts @@ -412,7 +412,7 @@ export const DraftListProvider = (element: HTMLElement) => { if (!doc || doc.mergedAt !== undefined) continue; drafts.push({ url, - parent: doc.parent ?? null, + parent: effectiveParent(doc.parent), members: clonesToMembers(doc.clones), childCount: doc.drafts.length, name: doc.name ?? null, @@ -427,6 +427,25 @@ export const DraftListProvider = (element: HTMLElement) => { }; } + // The parent a summary should point at: the nearest non-merged ancestor. + // Merging normally re-parents children, but drafts orphaned before that + // existed (or by a concurrent merge on another peer) still resolve to the + // draft their parent was merged into instead of dangling under a hidden + // one. Unknown urls (not tracked, e.g. the main draft) pass through as-is. + function effectiveParent( + parentUrl: AutomergeUrl | undefined + ): AutomergeUrl | null { + const seen = new Set(); + let cursor = parentUrl ?? null; + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + const doc = trackedDrafts.get(cursor)?.doc(); + if (!doc || doc.mergedAt === undefined) return cursor; + cursor = doc.parent ?? null; + } + return cursor; + } + // Main's summary. Its members come from the main draft's identity clones once // it exists; before the first draft is created (no main draft) we fall back to // the docs mounted beneath us, minus the app-global datatypes the overlay From 63ca71cfec34554194661da53f27a82e13161bb3 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 09:17:01 +0200 Subject: [PATCH 15/18] bring back the version footer in the drafts sidebar so deployed builds are identifiable --- drafts/src/DraftsSidebar.tsx | 7 ++++--- drafts/src/styles.css | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index a87da6e1..e405ce69 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -60,9 +60,9 @@ const EMPTY_DRAFT_LIST: DraftList = { actorAttributionUrl: null, }; -// Logged on load and stamped into fork diagnostics; bump on deploy to tell -// builds apart (no longer shown in the UI). -const DRAFTS_VERSION = "0.0.43"; +// Shown in the panel footer, logged on load, and stamped into fork +// diagnostics; bump on deploy to tell builds apart. +const DRAFTS_VERSION = "0.0.44"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -670,6 +670,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) {
+
v{DRAFTS_VERSION}
); } diff --git a/drafts/src/styles.css b/drafts/src/styles.css index e443a2d7..dd20b490 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -46,6 +46,14 @@ color: var(--drafts-faint-fg); } +/* Build stamp pinned below the list so deployed versions are identifiable. */ +.drafts-version { + margin-top: auto; + font-size: 0.65rem; + color: var(--drafts-faint-fg); + text-align: right; +} + .drafts-list { display: flex; flex-direction: column; From 22f7bdb92339d1d3119446933ad703832d6c86a3 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 10:09:17 +0200 Subject: [PATCH 16/18] editor read-only follows the handle across backing swaps: the readOnly extension tracks isReadOnly() live and the comment UI disables writes while read-only --- codemirror-base/src/lib/codemirror.tsx | 7 ++- .../src/lib/extensions/commentUI.ts | 16 +++++- .../src/lib/extensions/readOnly.ts | 55 +++++++++++++++---- codemirror-base/src/tool.tsx | 3 - 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/codemirror-base/src/lib/codemirror.tsx b/codemirror-base/src/lib/codemirror.tsx index 7e732e21..e50ed212 100644 --- a/codemirror-base/src/lib/codemirror.tsx +++ b/codemirror-base/src/lib/codemirror.tsx @@ -40,6 +40,8 @@ type CodeMirrorProps = { // When the returned range changes, the editor scrolls it into view -- unless // it's already visible. Used to follow focus driven by other views. scrollTarget?: () => readonly [number, number] | null; + // Forces the editor read-only. A heads-pinned handle makes it read-only + // regardless (tracked internally), so this is only needed as an override. readOnly?: boolean; withView?(view: EditorView): void; }; @@ -55,7 +57,10 @@ export function CodeMirror(props: CodeMirrorProps) { ); const [readOnlyExtension, createEffectReconfigureReadOnly] = - createReadOnlyExtension(() => !!props.readOnly); + createReadOnlyExtension( + () => props.handle, + () => !!props.readOnly + ); // Undo/redo lives here (not in tool-supplied extensions) so the stack can // be reset when the handle's backing is swapped in place -- see history.ts. diff --git a/codemirror-base/src/lib/extensions/commentUI.ts b/codemirror-base/src/lib/extensions/commentUI.ts index d0a3257d..4e3258c1 100644 --- a/codemirror-base/src/lib/extensions/commentUI.ts +++ b/codemirror-base/src/lib/extensions/commentUI.ts @@ -153,7 +153,8 @@ const buttonTooltip = ( options: CommentUIOptions ): Tooltip | null => { const sel = state.selection.main; - if (sel.empty) return null; + // A read-only editor can't take the comment write, so don't offer the button. + if (sel.empty || state.readOnly) return null; // Anchor the button at the drag tail (the selection head) rather than the // start, so dragging left-to-right lands it on the right and right-to-left @@ -203,6 +204,15 @@ const commentUIField = (options: CommentUIOptions, ui: CommentUIState) => let buttonDismissed = value.buttonDismissed; let popoverChanged = false; + // Read-only can flip mid-session: entering it closes any open popover — + // its reply box writes to a doc that rejects writes — and the flip + // itself must recompute the tooltip so the button hides/re-arms. + const readOnlyChanged = tr.startState.readOnly !== tr.state.readOnly; + if (readOnlyChanged && tr.state.readOnly && popover) { + popover = null; + popoverChanged = true; + } + // Keep the popover pinned to its text as the document shifts under it. if (popover && tr.docChanged) { popover = { ...popover, anchor: tr.changes.mapPos(popover.anchor) }; @@ -248,6 +258,7 @@ const commentUIField = (options: CommentUIOptions, ui: CommentUIState) => !popoverChanged && !tr.docChanged && !tr.selection && + !readOnlyChanged && buttonDismissed === value.buttonDismissed ) { return value; @@ -283,6 +294,9 @@ const commentInteractions = ( if ((event.target as HTMLElement | null)?.closest("." + POPOVER_CLASS)) { return false; } + // Read-only: don't open threads either — the popover's reply box would + // write into a doc that rejects writes. + if (view.state.readOnly) return false; const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); if (pos == null) return false; const hit = options.threadAtPos(pos); diff --git a/codemirror-base/src/lib/extensions/readOnly.ts b/codemirror-base/src/lib/extensions/readOnly.ts index a57d1ca8..074d2bab 100644 --- a/codemirror-base/src/lib/extensions/readOnly.ts +++ b/codemirror-base/src/lib/extensions/readOnly.ts @@ -1,31 +1,66 @@ -import { createEffect } from "solid-js"; +import { createEffect, createSignal, onCleanup } from "solid-js"; /** CodeMirror */ import { EditorView } from "@codemirror/view"; import { Compartment, EditorState } from "@codemirror/state"; +/** Automerge */ +import type { + DocHandle, + DocHandleChangePayload, +} from "@automerge/automerge-repo/slim"; + /** - * Create a CodeMirror extension for controlling read-only mode using a CodeMirror Compartment. - * @param readOnly Whether the editor should be read-only. - * @returns A tuple containing the extension and a function to create an effect for reconfiguring - the extension when the readOnly prop changes. + * Create a CodeMirror extension that makes the editor read-only whenever the + * handle is (`handle.isReadOnly()`, e.g. a heads-pinned view) or the `force` + * override says so. + * + * The handle's read-only state can flip in place: its backing may be swapped + * without the handle identity changing (a `change` event with + * `scopeReplaced: true`), so it is tracked as a signal re-read on every swap + * rather than sampled once. + * + * @param handle The Automerge document handle. + * @param force Forces read-only regardless of the handle's state. + * @returns A tuple containing the extension and a function to create the + * effects that track the handle and reconfigure the extension. */ -export function createReadOnlyExtension(readOnly: () => boolean) { +export function createReadOnlyExtension( + handle: () => DocHandle | undefined, + force: () => boolean = () => false +) { const readOnlyCompartment = new Compartment(); - // Function to get the desired state of the read-only extensions based on the readOnly parameter + // Seeded at factory time so the initial EditorState is already correct when + // the editor mounts on a read-only handle. + const [handleReadOnly, setHandleReadOnly] = createSignal( + !!handle()?.isReadOnly() + ); + const readOnlyExtensions = () => - readOnly() + force() || handleReadOnly() ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []; - // Function to create an effect that reconfigures the read-only compartment - const createReconfigureEffect = (view: EditorView) => + const createReconfigureEffect = (view: EditorView) => { + // Follow the handle's read-only state across backing swaps. + createEffect(() => { + const h = handle(); + if (!h) return; + setHandleReadOnly(h.isReadOnly()); + const onChange = (payload: DocHandleChangePayload) => { + if (payload.scopeReplaced) setHandleReadOnly(h.isReadOnly()); + }; + h.on("change", onChange); + onCleanup(() => h.off("change", onChange)); + }); + createEffect(() => { view.dispatch({ effects: readOnlyCompartment.reconfigure(readOnlyExtensions()), }); }); + }; return [ readOnlyCompartment.of(readOnlyExtensions()), diff --git a/codemirror-base/src/tool.tsx b/codemirror-base/src/tool.tsx index 7d0f4ff5..71bcc0e5 100644 --- a/codemirror-base/src/tool.tsx +++ b/codemirror-base/src/tool.tsx @@ -50,8 +50,6 @@ type Baseline = { heads: UrlHeads | null }; const PATH = ["content"]; export function CodeMirrorEditor(props: PatchworkToolProps) { - const isReadOnly = props.handle.isReadOnly(); - // Diff baseline from the active draft overlay; plain JSON `{ heads }`. Fed to // the CodeMirror diff extension, which recomputes spans on every doc change // (driven by the sync plugin's transactions, so no manual tick is needed). @@ -269,7 +267,6 @@ export function CodeMirrorEditor(props: PatchworkToolProps) { decorations={decorations} baseline={() => baseline()?.heads ?? null} extensions={extensions()} - readOnly={isReadOnly} onChangeSelection={onChangeSelection} scrollTarget={scrollTarget} /> From 0bdc8d41afa7d287d10b624abf428ae00e707254 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 12:53:58 +0200 Subject: [PATCH 17/18] tldraw read-only follows the handle across backing swaps: track isReadOnly() live and gate the store write-back behind a ref; align tldraw4's automerge-repo pins with @automerge/react --- pnpm-lock.yaml | 43 +++++++++++------------ tldraw4/package.json | 4 +-- tldraw4/src/lith/useAutomergeStore.ts | 50 ++++++++++++++++++++------- tldraw4/src/tool.tsx | 8 +++-- 4 files changed, 66 insertions(+), 39 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb64062a..bfffda8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -812,7 +812,7 @@ importers: version: 2.6.0-subduction.46(supports-color@7.2.0) '@inkandswitch/patchwork-elements': specifier: 1.0.0 - version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) + version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) '@inkandswitch/patchwork-filesystem': specifier: ^0.1.3 version: 0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) @@ -1227,14 +1227,14 @@ importers: tldraw4: dependencies: '@automerge/automerge-repo-react-hooks': - specifier: ^2.6.0-subduction.9 - version: 2.6.0-subduction.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0) + specifier: ^2.6.0-subduction.46 + version: 2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0) '@automerge/react': specifier: ^2.6.0-subduction.9 version: 2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0) '@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.1))(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.2)(@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) + version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.1))(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))(@automerge/automerge@3.3.2)(@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) '@inkandswitch/patchwork-filesystem': specifier: ^0.0.3 version: 0.0.3(supports-color@7.2.0) @@ -1243,10 +1243,10 @@ importers: version: 0.0.5(supports-color@7.2.0) '@inkandswitch/patchwork-providers': specifier: 0.3.0 - version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0)) + version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0)) '@inkandswitch/patchwork-providers-react': specifier: 0.2.2 - version: 0.2.2(@automerge/automerge-repo-react-hooks@2.6.0-subduction.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(react@18.3.1) + version: 0.2.2(@automerge/automerge-repo-react-hooks@2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))(react@18.3.1) '@tldraw/tldraw': specifier: 4.3.1 version: 4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1273,8 +1273,8 @@ importers: specifier: ^3.2.5 version: 3.3.2 '@automerge/automerge-repo': - specifier: ^2.6.0-subduction.9 - version: 2.6.0-subduction.39(supports-color@7.2.0) + specifier: ^2.6.0-subduction.46 + version: 2.6.0-subduction.46(supports-color@7.2.0) '@eslint/js': specifier: ^9.36.0 version: 9.39.5 @@ -7764,11 +7764,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0)': + '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39(supports-color@7.2.0) '@inkandswitch/patchwork-filesystem': 0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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.39(supports-color@7.2.0)) debug: 4.4.3(supports-color@7.2.0) optionalDependencies: @@ -8111,18 +8111,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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-repo': 2.6.0-subduction.39(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.1.3(@automerge/automerge-repo@2.6.0-subduction.39(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) - eventemitter3: 5.0.4 - resolve.exports: 2.0.3 - transitivePeerDependencies: - - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@7.2.0))(@automerge/automerge@3.3.2)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@7.2.0))(@automerge/automerge@3.3.2)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.2 @@ -8247,6 +8235,13 @@ snapshots: '@inkandswitch/patchwork-providers': 0.2.2(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0)) react: 18.3.1 + '@inkandswitch/patchwork-providers-react@0.2.2(@automerge/automerge-repo-react-hooks@2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))(react@18.3.1)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.46(supports-color@7.2.0) + '@automerge/automerge-repo-react-hooks': 2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': 0.2.2(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0)) + react: 18.3.1 + '@inkandswitch/patchwork-providers-solid@0.2.3(@automerge/automerge-repo-solid-primitives@2.5.6(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.14))(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(solid-js@1.9.14)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39(supports-color@7.2.0) @@ -8273,6 +8268,10 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39(supports-color@7.2.0) + '@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.46(supports-color@7.2.0) + '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39(supports-color@7.2.0) diff --git a/tldraw4/package.json b/tldraw4/package.json index b6545984..3b80e624 100644 --- a/tldraw4/package.json +++ b/tldraw4/package.json @@ -27,7 +27,7 @@ "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" }, "dependencies": { - "@automerge/automerge-repo-react-hooks": "^2.6.0-subduction.9", + "@automerge/automerge-repo-react-hooks": "^2.6.0-subduction.46", "@automerge/react": "^2.6.0-subduction.9", "@inkandswitch/patchwork-bootloader": "^0.2.8", "@inkandswitch/patchwork-filesystem": "^0.0.3", @@ -44,7 +44,7 @@ }, "devDependencies": { "@automerge/automerge": "^3.2.5", - "@automerge/automerge-repo": "^2.6.0-subduction.9", + "@automerge/automerge-repo": "^2.6.0-subduction.46", "@eslint/js": "^9.36.0", "@types/node": "^24.6.0", "@types/react": "^18.3.1", diff --git a/tldraw4/src/lith/useAutomergeStore.ts b/tldraw4/src/lith/useAutomergeStore.ts index 73ea4d5a..698a941c 100644 --- a/tldraw4/src/lith/useAutomergeStore.ts +++ b/tldraw4/src/lith/useAutomergeStore.ts @@ -16,7 +16,7 @@ import { sortById, useEditor, } from "@tldraw/tldraw"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { type DocHandle, type DocHandleChangePayload, @@ -50,6 +50,12 @@ export function useAutomergeStore({ status: "loading", }); + // Read through a ref inside the write-back listener so a mid-session flip + // (see `useIsHandleReadOnly`) doesn't re-run the store effect below — that + // would re-load the snapshot and clear session records (camera, selection). + const readOnlyRef = useRef(readOnly); + readOnlyRef.current = readOnly; + /* -------------------- TLDraw <--> Automerge -------------------- */ useEffect(() => { const unsubs: (() => void)[] = []; @@ -62,6 +68,9 @@ export function useAutomergeStore({ function syncStoreChangesToAutomergeDoc({ changes, }: HistoryEntry) { + // A read-only (history-pinned) handle is at fixed heads and rejects + // `handle.change`, so never forward store edits back to Automerge. + if (readOnlyRef.current) return; preventPatchApplications = true; handle.change((doc) => { applyTLStoreChangesToAutomerge(doc, changes); @@ -69,16 +78,12 @@ export function useAutomergeStore({ preventPatchApplications = false; } - // A read-only (history-pinned) handle is at fixed heads and rejects - // `handle.change`, so never forward store edits back to Automerge. - if (!readOnly) { - unsubs.push( - store.listen(syncStoreChangesToAutomergeDoc, { - source: "user", - scope: "document", - }) - ); - } + unsubs.push( + store.listen(syncStoreChangesToAutomergeDoc, { + source: "user", + scope: "document", + }) + ); /* Automerge to TLDraw */ const syncAutomergeDocChangesToStore = ({ @@ -153,11 +158,32 @@ export function useAutomergeStore({ unsubs.forEach((fn) => fn()); unsubs.length = 0; }; - }, [handle, store, readOnly]); + }, [handle, store]); return storeWithStatus; } +// A handle's read-only state (`isReadOnly()`, true at fixed heads) can flip in +// place: its backing may be swapped without the handle identity changing (a +// `change` event with `scopeReplaced: true`). Track it as state re-read on +// every swap rather than sampling it once per mount. +export function useIsHandleReadOnly( + handle: DocHandle +): boolean { + const [readOnly, setReadOnly] = useState(() => handle.isReadOnly()); + + useEffect(() => { + setReadOnly(handle.isReadOnly()); + const onChange = (payload: DocHandleChangePayload) => { + if (payload.scopeReplaced) setReadOnly(handle.isReadOnly()); + }; + handle.on("change", onChange); + return () => void handle.off("change", onChange); + }, [handle]); + + return readOnly; +} + // A scope swap (a `change` event with `scopeReplaced: true` -- the draft // overlay re-pointing this handle at a different clone when scrubbing history // or switching drafts) invalidates the undo stack: its entries describe a diff --git a/tldraw4/src/tool.tsx b/tldraw4/src/tool.tsx index cb35aec9..97ac68c0 100644 --- a/tldraw4/src/tool.tsx +++ b/tldraw4/src/tool.tsx @@ -24,6 +24,7 @@ import { useAutomergeStore, useAutomergePresence, useClearHistoryOnScopeSwap, + useIsHandleReadOnly, } from "./lith/useAutomergeStore.ts"; import type { TLDrawDoc } from "./datatype.ts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -120,9 +121,10 @@ export function TldrawTool({ element: HTMLElement; }) { const handle = useDocHandle(docUrl, { suspense: true }); - // A history-pinned handle (url carries heads) is at fixed heads and rejects - // writes, so the whole tool renders read-only. - const readOnly = handle.isReadOnly(); + // A history-pinned handle is at fixed heads and rejects writes, so the whole + // tool renders read-only. Tracked live: it flips in place when the handle's + // backing is swapped. + const readOnly = useIsHandleReadOnly(handle); const contactInfo = useContactInfo(); const store = useAutomergeStore({ handle, From ceb5a79cbb09a761bdb554a042620d86b2d79757 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 15:20:21 +0200 Subject: [PATCH 18/18] drafts are deep-linkable via a draft= hash param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list provider mirrors the checked-out draft into the URL (replaceState, so no re-route or history spam) and applies an incoming draft= once the draft appears in this doc's tree — so links work even before the draft doc has synced. The router clears the param when navigating to another doc. --- drafts/src/DraftsSidebar.tsx | 2 +- drafts/src/providers/DraftListProvider.ts | 109 +++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 0b7e5529..3e52f7bf 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -62,7 +62,7 @@ const EMPTY_DRAFT_LIST: DraftList = { // Shown in the panel footer, logged on load, and stamped into fork // diagnostics; bump on deploy to tell builds apart. -const DRAFTS_VERSION = "0.0.44"; +const DRAFTS_VERSION = "0.0.45"; // Logged at module load so the console shows which build is running even // before the panel renders. diff --git a/drafts/src/providers/DraftListProvider.ts b/drafts/src/providers/DraftListProvider.ts index 0a19ae4a..3d8b7cf0 100644 --- a/drafts/src/providers/DraftListProvider.ts +++ b/drafts/src/providers/DraftListProvider.ts @@ -44,6 +44,13 @@ const ATTR_DOC_URL = "doc-url"; // "from the start", so `getChangesMetaSince(doc, [])` yields the full history. const EMPTY_HEADS: UrlHeads = encodeHeads([]); +// Deep links: the current selection is mirrored into a `draft=` hash param +// (an AutomergeUrl, written via `replaceState` so it never re-routes), and a +// `draft=` found in the URL is applied once the draft shows up in this doc's +// tree. The router treats the param as doc-scoped and opaque: it drops it +// when navigating to a different document and otherwise carries it along. +const DRAFT_PARAM = "draft"; + // 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 @@ -142,6 +149,13 @@ export const DraftListProvider = (element: HTMLElement) => { let disposed = false; let rewalkInFlight = false; let rewalkPending = false; + // A deep-linked draft (`#draft=`) waiting to be claimed: applied by the + // first rewalk that finds it in this doc's tree (it may not have synced + // yet), cancelled by any explicit selection made in the meantime. + let pendingUrlDraft: AutomergeUrl | null = readDraftParam(); + // Last selection this provider observed, so checkout-doc writes that don't + // move the selection (scrubber checkpoints) don't touch the URL. + let lastCheckedOut: AutomergeUrl | 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 = () => { @@ -150,11 +164,42 @@ export const DraftListProvider = (element: HTMLElement) => { }; const onHostDocChange = () => scheduleRewalk(); // The checkout doc changed: its `at` checkpoint may have been set, cleared, or - // moved, so re-publish every live `draft:baseline` subscriber. + // moved, so re-publish every live `draft:baseline` subscriber. If the + // selection itself moved, mirror it into the `draft=` hash param. const onCheckedOutChange = () => { + syncSelectionToUrl(); notifyBaselines(); }; + // The hash changed under us (pasted link, back/forward, router navigation): + // reconcile the `draft=` param with the selection. Our own writes go through + // `replaceState` and never fire this. + const onHashChange = () => { + if (disposed || !checkedOutHandle) return; + const urlDraft = readDraftParam(); + const selected = checkedOutHandle.doc()?.checkedOut ?? null; + if (urlDraft === selected) { + pendingUrlDraft = null; + return; + } + if (urlDraft === null) { + // The param was removed out from under us: drop back to main. + pendingUrlDraft = null; + checkedOutHandle.change((d) => { + d.checkedOut = null; + }); + return; + } + if (orderedDraftUrls.includes(urlDraft)) { + checkedOutHandle.change((d) => { + d.checkedOut = urlDraft; + }); + } else { + // Not (yet) in this doc's tree: hold it for the next rewalk. + pendingUrlDraft = urlDraft; + } + }; + const onMounted = (event: MountedEvent) => { const detail = event.detail; if (!("url" in detail)) return; @@ -283,6 +328,7 @@ export const DraftListProvider = (element: HTMLElement) => { element.addEventListener("patchwork:subscribe", onSubscribe); element.addEventListener("patchwork:mounted", onMounted); element.addEventListener("patchwork:unmounted", onUnmounted); + window.addEventListener("hashchange", onHashChange); return () => { disposed = true; @@ -291,6 +337,7 @@ export const DraftListProvider = (element: HTMLElement) => { element.removeEventListener("patchwork:subscribe", onSubscribe); element.removeEventListener("patchwork:mounted", onMounted); element.removeEventListener("patchwork:unmounted", onUnmounted); + window.removeEventListener("hashchange", onHashChange); if (hostDocHandle) hostDocHandle.off("change", onHostDocChange); if (mainDraftHandle) mainDraftHandle.off("change", onTrackedChange); for (const [, h] of trackedDrafts) h.off("change", onTrackedChange); @@ -336,6 +383,17 @@ export const DraftListProvider = (element: HTMLElement) => { if (disposed) return; orderedDraftUrls = allDrafts; + // Claim a deep-linked draft once it's confirmed in this doc's tree. + if (pendingUrlDraft && allDrafts.includes(pendingUrlDraft)) { + const target = pendingUrlDraft; + pendingUrlDraft = null; + if (liveCheckedOut.doc()?.checkedOut !== target) { + liveCheckedOut.change((d) => { + d.checkedOut = target; + }); + } + } + // Reconcile the checkout pointer: if the checked-out draft is gone // (merged or detached), fall back to main. const selected = liveCheckedOut.doc()?.checkedOut ?? null; @@ -493,6 +551,18 @@ export const DraftListProvider = (element: HTMLElement) => { } } + // Mirror the selection into the `draft=` hash param. Only runs when the + // selection actually moved; a selection made while a deep link was still + // pending also wins over (cancels) the pending link. + function syncSelectionToUrl(): void { + if (disposed) return; + const selected = checkedOutHandle?.doc()?.checkedOut ?? null; + if (selected === lastCheckedOut) return; + lastCheckedOut = selected; + if (pendingUrlDraft && selected !== pendingUrlDraft) pendingUrlDraft = null; + writeDraftParam(selected); + } + // Resolve (once, cached) whether a mounted doc is an app-global datatype we // exclude from the main-case membership. On failure we leave it unresolved, // so the doc stays visible — mirroring the overlay's "fall back to forking". @@ -641,3 +711,40 @@ function sameHeads(a: UrlHeads | null, b: UrlHeads | null): boolean { const set = new Set(b); return a.every((h) => set.has(h)); } + +function readDraftParam(): AutomergeUrl | null { + const raw = new URLSearchParams(window.location.hash.slice(1)).get( + DRAFT_PARAM + ); + return raw && isValidAutomergeUrl(raw) ? raw : null; +} + +function writeDraftParam(selected: AutomergeUrl | null): void { + const params = new URLSearchParams(window.location.hash.slice(1)); + if ((params.get(DRAFT_PARAM) ?? null) === selected) return; + if (selected) params.set(DRAFT_PARAM, selected); + else params.delete(DRAFT_PARAM); + // `replaceState` rather than assigning `location.hash`: no hashchange, so + // the router doesn't re-route, and draft switches don't pile up history. + try { + history.replaceState(null, "", "#" + serializeHash(params)); + } catch { + // Sandboxed realms (srcdoc previews) can refuse replaceState; the URL is + // cosmetic there, so silently skip. + } +} + +// Mirrors the router's hash serialization: automerge-URL values stay literal +// so links remain readable, everything else is percent-encoded. +const RAW_HASH_KEYS = new Set(["doc", DRAFT_PARAM]); + +function serializeHash(params: URLSearchParams): string { + const parts: string[] = []; + params.forEach((value, key) => { + if (!value) return; + parts.push( + `${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}` + ); + }); + return parts.join("&"); +}