diff --git a/codemirror-base/package.json b/codemirror-base/package.json index cdec637..a66d5f9 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 5a11729..e50ed21 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"; @@ -39,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; }; @@ -54,7 +57,16 @@ 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. + const [historyExtension, createEffectResetHistory] = createHistoryExtension( + () => props.handle + ); const [decorationsExtension, createEffectReconfigureDecorations] = createDecorationsExtension(() => props.decorations?.()); @@ -90,6 +102,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 +124,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 6fb4582..bbae288 100644 --- a/codemirror-base/src/lib/extensions/automergeSync.ts +++ b/codemirror-base/src/lib/extensions/automergeSync.ts @@ -1,20 +1,23 @@ -import { createEffect } from "solid-js"; +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/slim"; import { automergeSyncPlugin } from "@automerge/automerge-codemirror"; -import type { DocHandle } from "@automerge/automerge-repo/slim"; +import type { + DocHandle, + DocHandleChangePayload, +} from "@automerge/automerge-repo/slim"; /** * 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, @@ -35,18 +38,44 @@ 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(() => { - // 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(), + }, + annotations: [ + Transaction.addToHistory.of(false), + Transaction.remote.of(true), + ], + }); + }; + // 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/codemirror-base/src/lib/extensions/commentUI.ts b/codemirror-base/src/lib/extensions/commentUI.ts index d0a3257..4e3258c 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/history.ts b/codemirror-base/src/lib/extensions/history.ts new file mode 100644 index 0000000..e01606b --- /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/slim"; + +/** + * 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 4cf0ae5..c460577 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-base/src/lib/extensions/readOnly.ts b/codemirror-base/src/lib/extensions/readOnly.ts index a57d1ca..074d2ba 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 7d0f4ff..71bcc0e 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} /> diff --git a/codemirror-markdown/src/extensions/markdown.ts b/codemirror-markdown/src/extensions/markdown.ts index dfb7d5a..f71de21 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/contact/src/components/InlineContactAvatar.ts b/contact/src/components/InlineContactAvatar.ts index 299a2a5..fd1c26e 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/package.json b/drafts/package.json index dc7c5c5..6a8467b 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", @@ -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/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 0ea7342..3e52f7b 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -15,42 +15,59 @@ import type { Repo, UrlHeads, } from "@automerge/automerge-repo/slim"; -import { decodeHeads, encodeHeads } from "@automerge/automerge-repo/slim"; +import { + decodeHeads, + encodeHeads, + isValidAutomergeUrl, +} from "@automerge/automerge-repo/slim"; import * as Automerge from "@automerge/automerge/slim"; -import { getRegistry, isLoadedPlugin } from "@inkandswitch/patchwork-plugins"; import { subscribe, subscribeDoc, } from "@inkandswitch/patchwork-providers-solid"; import type { + ActorAttributionDoc, + CachedGroup, + ChangeGroupCacheDoc, CheckedOutDraft, CloneEntry, DraftCheckpoint, DraftDoc, DraftList, DraftMemberDoc, + DraftSummary, HasDrafts, } from "./draft-types"; +import { + computeEditCounts, + computeRangeEditCounts, + 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, + parent: null, + members: [], + childCount: 0, + name: null, + changeGroupCacheUrl: null, + }, drafts: [], + actorAttributionUrl: null, }; -// Bump on each deploy to eyeball whether the latest build has synced. -const DRAFTS_VERSION = "0.0.18"; +// 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.45"; // 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", @@ -70,6 +87,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 @@ -77,14 +109,12 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // survives). const [scrubber, setScrubber] = 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); + // 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); // The derived drafts list (read-only): main plus each draft with its member // docs, recomputed and pushed by the provider. @@ -94,6 +124,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. @@ -105,6 +166,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. @@ -119,21 +181,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. - 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 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); + 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) => { @@ -143,80 +211,184 @@ 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[] => + 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); + // 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) => { - d.at = null; + d.at = + baselines && Object.keys(baselines).length > 0 ? baselines : null; }); }; - const onCreateDraft = async () => { - if (isFolder()) return; - const docHandle = hostDocHandle(); - if (!docHandle) return; + // 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 (!repo) { - console.warn("[drafts] window.repo is not set"); + 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. + setBaseliner(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; } - // 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); + const draftUrl = selected(); + const s = scrubber(); + if (isPinned() && s) { + // 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, { + beforeTime: 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; }); - 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; + // 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 }; + } }); - 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 - // 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; @@ -226,37 +398,69 @@ 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); + 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; + // 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" }, - parent: mainDraft.url, + name: `Draft ${draftNumber}`, + parent: parentHandle.url, drafts: [], clones, }); - mainDraft.change((d) => { + parentHandle.change((d) => { d.drafts.push(draft.url); }); selectDraft(draft.url); @@ -282,10 +486,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"); @@ -293,7 +550,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 ( @@ -302,133 +594,114 @@ export function DraftsSidebar(props: { element: HTMLElement }) { when={hostDoc()} fallback={
No document selected.
} > - -
- - - - Drafts aren't supported for folders yet. - - -
-
list().main.members} + changeGroupCacheUrl={list().main.changeGroupCacheUrl} name={list().main.name} onRename={(name) => void onRename(null, name)} onSelect={() => selectDraft(null)} onScrub={(scrub) => onScrub(null, list().main.members, scrub)} scrubber={() => (isMainSelected() ? scrubber() : null)} - onDragVersion={(head) => - setDragVersion( - head ? { members: list().main.members, head } : null - ) + onBaselineScrub={(base) => + onBaselineScrub(null, list().main.members, base) } - hasCheckpoint={isMainSelected() && !!checkedOut()?.at} + baseliner={() => (isMainSelected() ? baseliner() : null)} + checkpoint={() => (isMainSelected() ? (checkedOut()?.at ?? null) : 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) => ( 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 } - onDragVersion={(head) => - setDragVersion( - head ? { members: summary.members, head } : null - ) + onBaselineScrub={(base) => + onBaselineScrub(summary.url, summary.members, base) + } + baseliner={() => + selected() === summary.url ? baseliner() : null } - hasCheckpoint={ - selected() === summary.url && !!checkedOut()?.at + checkpoint={() => + selected() === summary.url ? (checkedOut()?.at ?? null) : 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). 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 entries = Object.entries(draftHandle.doc()?.clones ?? {}) as [ + const doc = draftHandle.doc(); + const parentHandle = await findMergeTarget(repo, doc?.parent); + const parentClones = parentHandle?.doc()?.clones ?? {}; + 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; @@ -437,6 +710,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 ------------------------------------------- @@ -487,10 +821,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 +1001,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, }; }); @@ -738,17 +1068,35 @@ function MainCard(props: { hostDocUrl: AutomergeUrl | undefined; isSelected: boolean; members: Accessor; + changeGroupCacheUrl: AutomergeUrl | null; name: string | null; onRename: (name: string | null) => void; onSelect: () => void; onScrub: (scrub: ScrubberState) => void; scrubber: Accessor; - onDragVersion: (head: ChangeRef | null) => void; + onBaselineScrub: (base: BaselineState) => void; + baseliner: Accessor; + checkpoint: Accessor; 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 + - current + + + +
- props.eyeOpen} + checkpoint={props.checkpoint} + onReturnToLatest={props.onReturnToLatest} /> @@ -787,19 +1166,51 @@ function MainCard(props: { function DraftCard(props: { url: AutomergeUrl; members: DraftMemberDoc[]; + changeGroupCacheUrl: AutomergeUrl | null; 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; - onDragVersion: (head: ChangeRef | null) => void; + onBaselineScrub: (base: BaselineState) => void; + baseliner: Accessor; + checkpoint: Accessor; 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 + - current + + + +
props.members} + changeGroupCacheUrl={props.changeGroupCacheUrl} mainDocUrl={props.mainDocUrl} onScrub={props.onScrub} scrubber={props.scrubber} - onDragVersion={props.onDragVersion} - /> - props.eyeOpen} + checkpoint={props.checkpoint} + onReturnToLatest={props.onReturnToLatest} /> ); } -// 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 }) { +// 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 ( + + + + + ); } @@ -897,37 +1576,79 @@ 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; -}; +// 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 — 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; + onToggle: () => void; +}) { + return ( + + ); +} -// 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[]; -}; +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 @@ -938,122 +1659,329 @@ 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. +// `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; }; -// 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 }; -} +// 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 +// 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 -// the left spans the whole history (top = latest change, bottom = first); +// 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. 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. 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; + 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 [changes, setChanges] = createSignal([]); + const repo = "repo" in window ? window.repo : undefined; - // 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. + // 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)) + ); + + // 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) - ); + // 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; + }; - // 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) - ); + // 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). Null while the scan is still resolving. + const buildHead = ( + group: CachedGroup, + offset: number + ): ScrubberState | null => { + if (offset <= 0) { + return { + groupId: group.id, + offset: 0, + head: { + docUrl: group.newestMemberUrl, + hash: group.newestHash, + time: group.endTime, + }, + groupStartTime: group.startTime, + }; + } + const rows = resolveGroupChanges(group); + if (!rows || rows.length === 0) return null; + const row = rows[Math.min(offset, rows.length - 1)]; + return { + groupId: group.id, + offset, + head: { docUrl: row.docUrl, hash: row.hash, time: row.time }, + groupStartTime: group.startTime, + }; + }; - // 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) }); + // 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; }; - // Jump the scrubber to a group: head at the group's newest change. - const scrubToGroup = (group: TimeGroup) => { - props.onScrub({ head: changeRef(group.newest) }); + // 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); }; - // 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(() => { - 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; - }); + // 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); + }; - 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 - ) - ); + // 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), + }); + }; + + // 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; + // --- 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 @@ -1080,59 +2008,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 @@ -1140,8 +2062,29 @@ 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) }; + }); + + // 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; @@ -1157,19 +2100,29 @@ 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; + 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; - 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; @@ -1184,28 +2137,91 @@ 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); + // 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); }; - // 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 +/- 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); }); return ( @@ -1222,12 +2238,7 @@ function DraftChangesList(props: { rowEls.set(group.id, el)} - isSelected={groupContainsHead(group)} - onSelect={() => scrubToGroup(group)} - onVersionDragStart={(e) => - beginVersionDrag(e, changeRef(group.newest)) - } - onVersionDragEnd={() => props.onDragVersion(null)} + onSelect={() => selectGroup(group)} /> )} @@ -1235,50 +2246,72 @@ 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 - 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, changeRef(change())) - } - onDragEnd={() => props.onDragVersion(null)} + title="The version being viewed — Fork below to branch from here" > {formatTime(change().time)} - {change().title}
)}
+ {/* 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. */} + +
+
+
+
+
+
+
@@ -1287,30 +2320,20 @@ 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). function TimeGroupRow(props: { - group: TimeGroup; + group: CachedGroup; rowRef: (el: HTMLElement) => void; - isSelected: boolean; onSelect: () => void; - onVersionDragStart: (e: DragEvent) => void; - onVersionDragEnd: () => void; }) { return (