Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 8b36294b7b test(editor): pin remote-delete cursor contract + correct scope docblock (#196 review round 1)
- F2: add a remap test for a REMOTE (meta-less) delete of a cursor's own range,
  pinning the collapse-not-drop contract — the deleted-over cursor collapses to a
  zero-width caret at the deletion point and stays in the set; others keep their
  occurrence (this is the riskiest remap path, previously covered only for insert).
- F1: reword the out-of-scope docblock — occurrences inside tables/code-blocks/
  callouts ARE matched and edited as plain text (no schema-aware cursor); they
  were never a 'deliberately not built' boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 04:41:06 +03:00
agent_coder 0fa2f9fb91 feat(editor): multi-cursor editing MVP — select all occurrences + type into all (#196)
Variant A of #196: VS Code-style multi-cursor limited to "select all
occurrences of a word (or the selection) and type into all at once", built on
top of the existing SearchAndReplace mass-transaction machinery.

- New `MultiCursor` Tiptap extension (packages/editor-ext/src/lib/multi-cursor/):
  Cmd/Ctrl+Shift+L selects all occurrences, Cmd/Ctrl+D adds the next, typing /
  Backspace / Delete apply to every cursor in ONE reverse-order transaction (so a
  single undo reverts the whole multi-edit), Esc / click / navigation collapse.
- Cursors live in plugin state and are remapped on every docChanged — covering
  remote Yjs edits (applied as ordinary transactions) with no collab-specific code.
- Extracted a shared `findOccurrences` util so SearchAndReplace and MultiCursor
  no longer duplicate the occurrence walk (behaviour-preserving).
- Conscious v1 out-of-scope boundaries (Variant B) documented in the extension.

Registered in mainExtensions; carets styled distinctly from collaborative carets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 04:24:34 +03:00
32 changed files with 1318 additions and 1087 deletions
-8
View File
@@ -12,14 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Save intentional page versions.** Press `Cmd/Ctrl+S` (or use the page menu)
to save a named version of a page. The history panel now distinguishes
intentional versions (a "Saved" / "Agent version" badge) from automatic
snapshots, dims autosaves, and offers an "Only versions" filter. Automatic
snapshots switched from a fixed interval to a trailing idle-flush with a
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
changes (e.g. a person's edits followed by the AI agent). (#370)
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is
@@ -1385,14 +1385,5 @@
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
"Dismiss": "Dismiss",
"Suggestion dismissed": "Suggestion dismissed",
"Failed to dismiss suggestion": "Failed to dismiss suggestion",
"Save version": "Save version",
"Ctrl+S": "Ctrl+S",
"Version saved": "Version saved",
"Already saved as the latest version": "Already saved as the latest version",
"Agent version": "Agent version",
"Boundary": "Boundary",
"Autosave": "Autosave",
"Only versions": "Only versions",
"No saved versions yet.": "No saved versions yet."
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
}
@@ -1248,14 +1248,5 @@
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
"Dismiss": "Не применять",
"Suggestion dismissed": "Предложение отклонено",
"Failed to dismiss suggestion": "Не удалось отклонить предложение",
"Save version": "Сохранить версию",
"Ctrl+S": "Ctrl+S",
"Version saved": "Версия сохранена",
"Already saved as the latest version": "Уже сохранено как последняя версия",
"Agent version": "Версия агента",
"Boundary": "Граница",
"Autosave": "Автосейв",
"Only versions": "Только версии",
"No saved versions yet.": "Пока нет сохранённых версий."
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
}
@@ -1,19 +1,10 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
export const pageEditorAtom = atom<Editor | null>(null);
// #370 — the active page's collab provider, published by the page editor so the
// header menu can emit the "save-version" stateless signal (Cmd+S / button).
// Null when the page is read-only / collab isn't connected. A typed initial
// value (rather than an explicit generic) keeps jotai's overload resolution on
// the writable PrimitiveAtom branch.
const initialCollabProvider: HocuspocusProvider | null = null;
export const collabProviderAtom = atom(initialCollabProvider);
export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
@@ -45,6 +45,7 @@ import {
TiptapPdf,
PageBreak,
SearchAndReplace,
MultiCursor,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
@@ -447,6 +448,10 @@ export const mainExtensions = [
};
},
}).configure(),
// Multi-cursor editing (MVP / Variant A): select-all-occurrences + type into
// all at once. Does not depend on collaboration, so it lives in mainExtensions
// (available in both the plain and collaborative editors).
MultiCursor,
Columns,
Column,
AutoJoiner.configure({
@@ -31,18 +31,11 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
collabProviderAtom,
currentPageEditModeAtom,
dictationAvailabilityAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { notifications } from "@mantine/notifications";
import {
VERSION_SAVED_MESSAGE_TYPE,
type VersionSavedMessage,
saveVersionPending,
} from "@/features/page-history/version-messages";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -130,7 +123,6 @@ export default function PageEditor({
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const setCollabProvider = useSetAtom(collabProviderAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
@@ -188,24 +180,6 @@ export default function PageEditor({
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
// #370 — a version was saved somewhere; live-refresh the history panel
// on every client. Only the client that pressed Save (tracked by the
// module-level flag) shows the confirmation toast.
if (message?.type === VERSION_SAVED_MESSAGE_TYPE) {
const versionMsg = message as VersionSavedMessage;
queryClient.invalidateQueries({
queryKey: ["page-history-list"],
});
if (saveVersionPending.current) {
saveVersionPending.current = false;
notifications.show({
message: versionMsg.alreadySaved
? t("Already saved as the latest version")
: t("Version saved"),
});
}
return;
}
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
@@ -263,16 +237,12 @@ export default function PageEditor({
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
// #370 — publish the provider so the header menu can emit save-version.
setCollabProvider(remote);
setProvidersReady(true);
} else {
setCollabProvider(providersRef.current.remote);
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
setCollabProvider(null);
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
@@ -1,5 +1,6 @@
@import "./core.css";
@import "./collaboration.css";
@import "./multi-cursor.css";
@import "./task-list.css";
@import "./placeholder.css";
@import "./drag-handle.css";
@@ -0,0 +1,60 @@
/*
* Multi-cursor (issue #196). Deliberately DISTINCT from the collaboration
* carets (collaboration.css) so a user never confuses their own multi-cursors
* with a co-author's caret: solid accent-blue carets + a translucent blue
* range highlight, versus the thin dark collaboration caret with a name label.
*/
/* A secondary caret rendered as a Decoration.widget at each cursor position. */
.multi-cursor__caret {
position: relative;
display: inline-block;
width: 0;
height: 1em;
vertical-align: text-bottom;
pointer-events: none;
}
.multi-cursor__caret::after {
content: "";
position: absolute;
left: -1px;
top: 0;
bottom: 0;
width: 2px;
background: #2b6cb0;
animation: multi-cursor-blink 1s steps(1) infinite;
}
/* Optional label class reserved for future per-cursor annotations. */
.multi-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 0.7rem;
line-height: normal;
padding: 0.05rem 0.25rem;
border-radius: 3px 3px 3px 0;
background: #2b6cb0;
color: #fff;
white-space: nowrap;
user-select: none;
pointer-events: none;
}
/* Inline highlight for a multi-cursor RANGE (from < to). */
.multi-cursor__selection {
background: rgba(43, 108, 176, 0.28);
border-radius: 2px;
}
@keyframes multi-cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@@ -1,11 +1,4 @@
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
Badge,
} from "@mantine/core";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { formattedDate } from "@/lib/time";
@@ -14,59 +7,36 @@ import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
const MAX_VISIBLE_AVATARS = 5;
/**
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
* marks the intentional points (manual / agent); autosaves (boundary / idle /
* legacy null) are non-versions and get dimmed in the list.
*/
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
switch (kind) {
case "manual":
return { labelKey: "Saved", color: "blue", version: true };
case "agent":
return { labelKey: "Agent version", color: "violet", version: true };
case "boundary":
return { labelKey: "Boundary", color: "gray", version: false };
default: // "idle" | null | undefined (legacy autosave)
return { labelKey: "Autosave", color: "gray", version: false };
}
}
interface HistoryItemProps {
historyItem: IPageHistory;
// The previous snapshot for diff/restore is resolved by id from the FULL list
// in the parent (resolvePrevSnapshotId), so the item only needs to report its
// own id — never a list index (which would be the filtered-view index).
onSelect: (id: string) => void;
onHover?: (id: string) => void;
index: number;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
}
const HistoryItem = memo(function HistoryItem({
historyItem,
index,
onSelect,
onHover,
onHoverEnd,
isActive,
}: HistoryItemProps) {
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { t } = useTranslation();
const kindMeta = historyKindMeta(historyItem.kind);
const handleClick = useCallback(() => {
onSelect(historyItem.id);
}, [onSelect, historyItem.id]);
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id);
}, [onHover, historyItem.id]);
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0;
@@ -79,20 +49,8 @@ const HistoryItem = memo(function HistoryItem({
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
// #370 — dim autosnapshots so intentional versions stand out.
style={{ opacity: kindMeta.version ? 1 : 0.55 }}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Badge
size="xs"
radius="sm"
variant={kindMeta.version ? "filled" : "light"}
color={kindMeta.color}
>
{t(kindMeta.labelKey)}
</Badge>
</Group>
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
@@ -9,7 +9,7 @@ import {
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
Button,
ScrollArea,
@@ -17,12 +17,9 @@ import {
Divider,
Loader,
Center,
Switch,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useHistoryRestore } from "@/features/page-history/hooks";
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
const PREFETCH_DELAY_MS = 150;
@@ -50,23 +47,6 @@ function HistoryList({ pageId }: Props) {
[pageHistoryData],
);
// #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy
// null), keep only intentional points (manual/agent). Filtering is over the
// already-loaded pages; the diff/restore still targets the true previous
// snapshot, so items carry their index within the FULL list.
const [onlyVersions, setOnlyVersions] = useState(false);
const isVersion = useCallback(
(kind?: string | null) => kind === "manual" || kind === "agent",
[],
);
const visibleItems = useMemo(
() =>
onlyVersions
? historyItems.filter((item) => isVersion(item.kind))
: historyItems,
[historyItems, onlyVersions, isVersion],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -80,13 +60,11 @@ function HistoryList({ pageId }: Props) {
}, []);
const handleHover = useCallback(
(historyId: string) => {
(historyId: string, index: number) => {
clearPrefetchTimeout();
prefetchTimeoutRef.current = setTimeout(() => {
prefetchPageHistory(historyId);
// The true previous snapshot in the FULL list (not the previous visible
// one under the "only versions" filter).
const prevId = resolvePrevSnapshotId(historyItems, historyId);
const prevId = historyItems[index + 1]?.id;
if (prevId) {
prefetchPageHistory(prevId);
}
@@ -100,11 +78,9 @@ function HistoryList({ pageId }: Props) {
}, [clearPrefetchTimeout]);
const handleSelect = useCallback(
(id: string) => {
(id: string, index: number) => {
setActiveHistoryId(id);
// Baseline = true previous snapshot in the FULL list, so the "only
// versions" filter never diffs/restores against the wrong item.
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
@@ -152,27 +128,12 @@ function HistoryList({ pageId }: Props) {
return (
<div>
<Group px="xs" py={6} justify="flex-end">
<Switch
size="xs"
checked={onlyVersions}
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
label={t("Only versions")}
/>
</Group>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{onlyVersions && visibleItems.length === 0 && (
<Center py="md">
<Text size="sm" c="dimmed">
{t("No saved versions yet.")}
</Text>
</Center>
)}
{visibleItems.map((historyItem) => (
{historyItems.map((historyItem, index) => (
<HistoryItem
key={historyItem.id}
historyItem={historyItem}
index={index}
onSelect={handleSelect}
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
@@ -24,10 +24,6 @@ export interface IPageHistory {
updatedAt: string;
lastUpdatedBy: IPageHistoryUser;
contributors?: IPageHistoryUser[];
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
// autosave. Derived server-side, drives the history badge + "versions" filter.
kind?: "manual" | "agent" | "idle" | "boundary" | null;
// Provenance markers copied off the page row when the snapshot was saved.
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
// (when present) deep-links to the chat that produced the edit.
@@ -1,42 +0,0 @@
import { describe, it, expect } from "vitest";
import { resolvePrevSnapshotId } from "./resolve-prev-snapshot";
// #370 F4 — the risky client path: with the "only versions" filter active, diff
// and restore must still baseline against the TRUE previous snapshot in the FULL
// list, never the previous VISIBLE version (which would skip the autosnapshots
// between two versions). These pin that the resolution is by FULL-list order.
describe("resolvePrevSnapshotId", () => {
// Newest-first, as the history list stores it: a version, then two autosaves,
// then an older version.
const full = [
{ id: "v2", kind: "manual" },
{ id: "a2", kind: "idle" },
{ id: "a1", kind: "boundary" },
{ id: "v1", kind: "manual" },
{ id: "a0", kind: null },
];
it("returns the immediate FULL-list successor, not the previous visible version", () => {
// Selecting v2 while filtered to versions-only must baseline against a2 (the
// real chronological predecessor), NOT v1 (the previous visible version).
expect(resolvePrevSnapshotId(full, "v2")).toBe("a2");
});
it("resolves an autosnapshot's predecessor by full-list order", () => {
expect(resolvePrevSnapshotId(full, "a1")).toBe("v1");
});
it("returns '' for the oldest item (no predecessor)", () => {
expect(resolvePrevSnapshotId(full, "a0")).toBe("");
});
it("returns '' for an id not in the list", () => {
expect(resolvePrevSnapshotId(full, "missing")).toBe("");
});
it("does not depend on a filtered subset — same result whatever is visible", () => {
// The helper only ever sees the full list; a filtered view cannot change the
// baseline it computes.
expect(resolvePrevSnapshotId(full, "v1")).toBe("a0");
});
});
@@ -1,22 +0,0 @@
/**
* #370 — resolve the TRUE previous snapshot for a history item.
*
* The history panel can be filtered to "only versions" (manual/agent), but diff
* and restore must always compare against the immediately-preceding snapshot in
* the FULL, unfiltered list — NOT the previous VISIBLE item. Comparing against
* the previous visible version would silently skip the autosnapshots between two
* versions and diff/restore the wrong baseline.
*
* Given the full (newest-first) list and an item id, this returns the id of the
* item right after it in the full list (its chronological predecessor), or "" if
* it is the oldest / not found. Pure and list-order-preserving so it can be unit
* tested without mounting the component.
*/
export function resolvePrevSnapshotId(
fullItems: ReadonlyArray<{ id: string }>,
id: string,
): string {
const index = fullItems.findIndex((item) => item.id === id);
if (index === -1) return "";
return fullItems[index + 1]?.id ?? "";
}
@@ -1,28 +0,0 @@
/**
* #370 — page-version stateless wire formats. Kept in one place so the client
* emitter (Save hotkey / button) and the client listener (page-editor) agree
* with the server (PersistenceExtension) on the message shapes.
*/
/** Client → server: "save a version now". The server derives the tier
* (manual/agent) from the signed connection actor, never from this payload. */
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
/** Server → all clients: a version was saved (or promoted / already existed). */
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
export interface VersionSavedMessage {
type: typeof VERSION_SAVED_MESSAGE_TYPE;
historyId: string;
kind: "manual" | "agent";
/** True when the latest snapshot was already a manual version (a no-op save). */
alreadySaved: boolean;
}
/**
* Cross-component coordination flag so only the client that pressed Save shows
* the confirmation toast, while every other client silently refreshes its
* history panel on the broadcast. A module-level ref avoids stale-closure
* pitfalls in the editor's long-lived stateless handler.
*/
export const saveVersionPending = { current: false };
@@ -3,7 +3,6 @@ import {
IconArrowRight,
IconArrowsHorizontal,
IconClockHour4,
IconDeviceFloppy,
IconDots,
IconEye,
IconEyeOff,
@@ -18,7 +17,7 @@ import {
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@@ -40,14 +39,9 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
collabProviderAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
SAVE_VERSION_MESSAGE_TYPE,
saveVersionPending,
} from "@/features/page-history/version-messages.ts";
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
@@ -78,34 +72,9 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
});
const isDeleted = !!page?.deletedAt;
const [workspace] = useAtom(workspaceAtom);
const collabProvider = useAtomValue(collabProviderAtom);
// Community public-sharing entry point (replaces the removed EE PageShareModal)
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
// human; the server derives the tier from the signed actor. Readers can't save
// (the button is hidden and the collab connection is read-only server-side).
const handleSaveVersion = useCallback(() => {
if (readOnly || !collabProvider) return;
// Flag this client as the initiator so only it shows the confirmation toast;
// a safety timeout clears it if no broadcast comes back (e.g. offline).
saveVersionPending.current = true;
window.setTimeout(() => {
saveVersionPending.current = false;
}, 5000);
collabProvider.sendStateless(
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
);
}, [readOnly, collabProvider]);
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
// Editable` + empty ignore-list so it fires while typing in the editor/title.
useHotkeys(
[["mod+S", handleSaveVersion, { preventDefault: true }]],
[],
true,
);
useHotkeys(
[
[
@@ -164,16 +133,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</ActionIcon>
</Tooltip>
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
<PageActionMenu readOnly={readOnly} />
</>
);
}
interface PageActionMenuProps {
readOnly?: boolean;
onSaveVersion?: () => void;
}
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { t } = useTranslation();
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
@@ -334,20 +302,6 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
</Group>
</Menu.Item>
{!readOnly && (
<Menu.Item
leftSection={<IconDeviceFloppy size={16} />}
onClick={onSaveVersion}
rightSection={
<Text size="xs" c="dimmed">
{t("Ctrl+S")}
</Text>
}
>
{t("Save version")}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
+3 -29
View File
@@ -1,29 +1,3 @@
/**
* #370 — page-history intentionality tiers. Domain of `page_history.kind`.
* - 'manual' / 'agent' → Tier 1 versions (intentional points)
* - 'idle' / 'boundary' → Tier 0 autosnapshots (safety net)
* A legacy `null` kind is treated as an autosave.
*/
export type PageHistoryKind = 'manual' | 'agent' | 'idle' | 'boundary';
/**
* #370 — trailing idle-flush windows. A page's pending idle snapshot is
* re-armed on every store and fires this long after edits go quiet, so a burst
* of edits collapses into a single autosnapshot instead of one-per-store. Human
* sessions are noisier and less risky, so they flush less often than the agent.
*/
export const IDLE_INTERVAL_USER = 60 * 60 * 1000; // 60m
export const IDLE_INTERVAL_AGENT = 15 * 60 * 1000; // 15m
/**
* #370 — max-wait ceiling for the idle flush. Pure trailing debounce starves the
* safety net: hocuspocus stores at least every ~45s, so a CONTINUOUS editing
* session would re-arm the trailing timer forever and never take an idle
* snapshot until edits finally go quiet (up to IDLE_INTERVAL_USER = 60m). This
* ceiling bounds the actual wait from the FIRST edit of a burst, so an idle
* snapshot fires at least this often during a long unbroken session — restoring
* a recovery point cadence closer to the old heuristic without one-per-store
* noise. Mirrors hocuspocus's own maxDebounce idea.
*/
export const IDLE_MAX_WAIT_USER = 10 * 60 * 1000; // 10m
export const IDLE_MAX_WAIT_AGENT = 5 * 60 * 1000; // 5m
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -1,93 +1,84 @@
import { computeHistoryJob, resolveSource } from './persistence.extension';
import {
IDLE_INTERVAL_AGENT,
IDLE_INTERVAL_USER,
IDLE_MAX_WAIT_AGENT,
IDLE_MAX_WAIT_USER,
computeHistoryJob,
resolveSource,
} from './persistence.extension';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
// A fixed clock + fixed createdAt make pageAge deterministic.
const NOW = 1_700_000_000_000;
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const page = { id: PAGE_ID };
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
const pageAged = (ageMs: number) => ({
id: PAGE_ID,
createdAt: new Date(NOW - ageMs),
});
describe('computeHistoryJob (#370 — shared trailing idle pipeline)', () => {
it('human edit → user idle window, bare page.id job', () => {
// Humans and the agent now share ONE idle job per page (jobId = page.id).
// The agent's old delay=0 fast path is GONE — intentional agent points now
// arrive via the explicit save-version signal, not a zero-delay snapshot.
const { jobId, delay } = computeHistoryJob(page, 'user');
expect(delay).toBe(IDLE_INTERVAL_USER);
describe('computeHistoryJob', () => {
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
// The worker re-reads the page row at run time, so any non-zero delay risks
// snapshotting content a later human edit has already overwritten. This is
// the load-bearing assertion of this spec — do not relax it.
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
expect(delay).toBe(0);
expect(jobId).toBe(`${PAGE_ID}-agent`);
});
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
// Even when the page is far older than the fast threshold, the agent path
// must short-circuit to 0 — age-based debounce is a human-only concern.
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
'agent',
NOW,
);
expect(delay).toBe(0);
expect(jobId).toBe(`${PAGE_ID}-agent`);
});
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD - 1),
'user',
NOW,
);
expect(delay).toBe(HISTORY_FAST_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
it('agent edit → agent idle window (shorter), still the bare page.id job', () => {
const { jobId, delay } = computeHistoryJob(page, 'agent');
expect(delay).toBe(IDLE_INTERVAL_AGENT);
// No `-agent` suffix anymore: the agent joins the common idle pipeline.
it('human edit on an OLD page (age > threshold) → standard interval', () => {
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD + 1),
'user',
NOW,
);
expect(delay).toBe(HISTORY_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
it('agent flushes sooner than a human', () => {
expect(IDLE_INTERVAL_AGENT).toBeLessThan(IDLE_INTERVAL_USER);
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
const { delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD),
'user',
NOW,
);
expect(delay).toBe(HISTORY_INTERVAL);
});
it('treats any non-"agent" source string as human (keys strictly on === agent)', () => {
const { jobId, delay } = computeHistoryJob(page, 'user');
expect(delay).toBe(IDLE_INTERVAL_USER);
it('treats any non-"agent" source string as human', () => {
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
// the agent branch keys strictly on === 'agent'.
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
expect(delay).toBe(HISTORY_FAST_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
// #370 review round-1 WARNING: the max-wait ceiling prevents autosnapshot
// starvation during a continuous editing session (the trailing timer would
// otherwise re-arm forever and never fire).
describe('max-wait ceiling', () => {
const T0 = 1_000_000; // arbitrary fixed epoch for deterministic tests
it('once a burst is armed, delay clamps to the remaining max-wait budget', () => {
// 1 minute into the burst the USER interval (60m) far exceeds the remaining
// max-wait budget (10m - 1m = 9m), so the delay is clamped DOWN to that
// remaining budget — the full interval is NOT used once a ceiling applies.
const { delay } = computeHistoryJob(page, 'user', T0, T0 + 60_000);
expect(delay).toBe(IDLE_MAX_WAIT_USER - 60_000);
});
it('never waits longer than the max-wait budget from the burst start', () => {
// A store arriving right at the ceiling → delay 0 (fire promptly).
const { delay } = computeHistoryJob(
page,
'user',
T0,
T0 + IDLE_MAX_WAIT_USER,
);
expect(delay).toBe(0);
});
it('past the ceiling never returns a negative delay', () => {
const { delay } = computeHistoryJob(
page,
'user',
T0,
T0 + IDLE_MAX_WAIT_USER + 5 * 60_000,
);
expect(delay).toBe(0);
});
it('the agent ceiling is shorter than the user ceiling', () => {
expect(IDLE_MAX_WAIT_AGENT).toBeLessThan(IDLE_MAX_WAIT_USER);
const { delay } = computeHistoryJob(
page,
'agent',
T0,
T0 + IDLE_MAX_WAIT_AGENT,
);
expect(delay).toBe(0);
});
it('without a burstStart there is no ceiling (backward-compatible)', () => {
expect(computeHistoryJob(page, 'user').delay).toBe(IDLE_INTERVAL_USER);
expect(computeHistoryJob(page, 'agent').delay).toBe(IDLE_INTERVAL_AGENT);
});
});
});
describe('resolveSource (truth table)', () => {
@@ -40,12 +40,11 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
let pageHistoryRepo: {
saveHistory: jest.Mock;
findPageLastHistory: jest.Mock;
updateHistoryKind: jest.Mock;
};
let aiQueue: { add: jest.Mock };
let historyQueue: { add: jest.Mock; remove: jest.Mock };
let historyQueue: { add: jest.Mock };
let notificationQueue: { add: jest.Mock };
let collabHistory: { addContributors: jest.Mock; popContributors: jest.Mock };
let collabHistory: { addContributors: jest.Mock };
let transclusionService: {
syncPageTransclusions: jest.Mock;
syncPageReferences: jest.Mock;
@@ -94,22 +93,13 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
pageHistoryRepo = {
saveHistory: jest.fn().mockImplementation(async () => {
callOrder.push('saveHistory');
return { id: 'history-1' };
}),
findPageLastHistory: jest.fn().mockResolvedValue(null),
updateHistoryKind: jest.fn().mockResolvedValue(undefined),
};
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = {
add: jest.fn().mockResolvedValue(undefined),
// #370 — enqueuePageHistory now removes any pending idle job before re-adding.
remove: jest.fn().mockResolvedValue(undefined),
};
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = {
addContributors: jest.fn().mockResolvedValue(undefined),
popContributors: jest.fn().mockResolvedValue([]),
};
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
@@ -175,50 +165,6 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
});
// #370 review round-1 SUGGESTION: the boundary was GENERALIZED from a
// user→agent special-case to ANY lastUpdatedSource transition. These pin the
// generalized behaviour it was rebuilt for.
describe('generalized boundary — any source transition', () => {
// Same persisted page but with an explicit prior source.
const pageWithPriorSource = (prior: string | null) => ({
...persistedHumanPage('NEW CONTENT'),
lastUpdatedSource: prior,
});
it('agent→user transition fires the boundary (pins the prior agent revision)', async () => {
const document = ydocFor(doc('NEW CONTENT'));
pageRepo.findById.mockResolvedValue(pageWithPriorSource('agent'));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
});
it('git→user transition fires the boundary (git-sync overwrite is a source change)', async () => {
const document = ydocFor(doc('NEW CONTENT'));
pageRepo.findById.mockResolvedValue(pageWithPriorSource('git'));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
});
it('a null prior source (first-ever edit) does NOT fire the boundary', async () => {
const document = ydocFor(doc('NEW CONTENT'));
pageRepo.findById.mockResolvedValue(pageWithPriorSource(null));
await ext.onStoreDocument(buildData(document, 'agent') as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
});
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
// The Y.Doc content equals the persisted content deeply → early skip.
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
@@ -523,125 +469,4 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
});
// #370 — explicit save-version (Cmd+S / agent save tool) over the stateless
// seam. The tier is derived from the SIGNED connection actor, the store path
// is reused, and promote-not-dup avoids duplicating heavy content rows.
describe('save-version (#370)', () => {
const emitSave = (document: any, actor: 'user' | 'agent') =>
ext.onStateless({
connection: {
readOnly: false,
context: { user: { id: USER_ID, name: 'Alice' }, actor },
} as any,
documentName: `page.${PAGE_ID}`,
document: document as any,
payload: JSON.stringify({ type: 'save-version' }),
} as any);
// findById returns a page whose content already equals the live doc, so the
// store path is a no-op and we isolate the versioning decision.
const pageMatchingDoc = (document: any) => ({
...persistedHumanPage('IGNORED'),
content: TiptapTransformer.fromYdoc(document, 'default'),
});
it('human save with no prior snapshot → writes a manual version + broadcasts', async () => {
const document = ydocFor(doc('VERSION ME'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await emitSave(document, 'user');
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
expect(pageHistoryRepo.saveHistory.mock.calls[0][1]).toEqual(
expect.objectContaining({ kind: 'manual' }),
);
// The pending idle autosnapshot is cancelled by the explicit version.
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
);
expect(msg).toMatchObject({
type: 'version.saved',
kind: 'manual',
alreadySaved: false,
});
});
it('agent save derives kind=agent from the signed actor', async () => {
const document = ydocFor(doc('AGENT VERSION'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await emitSave(document, 'agent');
expect(pageHistoryRepo.saveHistory.mock.calls[pageHistoryRepo.saveHistory.mock.calls.length - 1][1]).toEqual(
expect.objectContaining({ kind: 'agent' }),
);
});
it('promote-not-dup: latest snapshot is an autosave with identical content → upgrades in place', async () => {
const document = ydocFor(doc('SAME'));
const page = pageMatchingDoc(document);
pageRepo.findById.mockResolvedValue(page);
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
id: 'auto-1',
content: page.content,
kind: 'idle',
});
await emitSave(document, 'user');
// No heavy new content row — the existing autosave is promoted to manual.
expect(pageHistoryRepo.updateHistoryKind).toHaveBeenCalledWith(
'auto-1',
'manual',
expect.anything(),
);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
);
expect(msg).toMatchObject({ historyId: 'auto-1', alreadySaved: false });
});
it('no-op when the latest snapshot is already a manual version of this content', async () => {
const document = ydocFor(doc('ALREADY SAVED'));
const page = pageMatchingDoc(document);
pageRepo.findById.mockResolvedValue(page);
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
id: 'ver-1',
content: page.content,
kind: 'manual',
});
await emitSave(document, 'user');
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
);
expect(msg).toMatchObject({ alreadySaved: true, kind: 'manual' });
});
it('a read-only connection cannot save a version', async () => {
const document = ydocFor(doc('READER'));
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
await ext.onStateless({
connection: {
readOnly: true,
context: { user: { id: USER_ID }, actor: 'user' },
} as any,
documentName: `page.${PAGE_ID}`,
document: document as any,
payload: JSON.stringify({ type: 'save-version' }),
} as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
});
});
});
@@ -36,11 +36,9 @@ import {
import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
IDLE_INTERVAL_AGENT,
IDLE_INTERVAL_USER,
IDLE_MAX_WAIT_AGENT,
IDLE_MAX_WAIT_USER,
PageHistoryKind,
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
@@ -53,16 +51,6 @@ import { observeCollabStore } from '../../integrations/metrics/metrics.registry'
*/
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
/**
* #370 wire format of the clientserver "save a version" signal. Sent by the
* human (Cmd+S / Save button) and by the agent's explicit save tool over the
* SAME stateless channel. The intentionality tier ('manual' vs 'agent') is
* derived SERVER-SIDE from the signed connection actor, never from this
* payload, so a version's type is unforgeable. The document is taken from the
* connection (not the payload), so the signal cannot be aimed at another page.
*/
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
/**
* #251 how long an intentional-clear signal stays "pending" before it is
* ignored. The signal is set on the clearing keystroke but consumed by the
@@ -99,39 +87,35 @@ export function resolveSource(
}
/**
* #370 compute the BullMQ job id + delay for a page's trailing idle-flush
* autosnapshot. Pure so the timing is unit-testable.
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
* (caller passes `Date.now()`) for determinism.
*
* Both humans and the agent now share ONE idle pipeline (the agent's old
* `delay=0` fast path is gone intentional agent points arrive via the
* explicit save-version signal instead). The job id is the bare `page.id`, so a
* page has at most one pending idle job; the caller removes-and-re-adds it on
* every store to keep it debounced to the trailing edge of an edit burst. The
* window differs by source only: the agent flushes sooner than a human.
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
* delay MUST stay 0 the worker re-reads the page row at run time, so any
* delay risks reading content a later human edit has already overwritten
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
* the job from coalescing with the bare-page.id human job.
* - Human edits: age-based debounce so rapid human edits coalesce into one
* snapshot; job id is the bare `page.id`.
*
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
*/
export function computeHistoryJob(
page: Pick<Page, 'id'>,
page: Pick<Page, 'id' | 'createdAt'>,
source: string,
// Epoch ms of the FIRST edit in the current burst (when the pending idle job
// was first armed). Used to enforce the max-wait ceiling so a continuous
// editing session cannot re-arm the trailing timer forever. `now` is injectable
// for tests; both default to a live clock / no ceiling when omitted.
burstStart?: number,
now: number = Date.now(),
now: number,
): { jobId: string; delay: number } {
const isAgent = source === 'agent';
const interval = isAgent ? IDLE_INTERVAL_AGENT : IDLE_INTERVAL_USER;
const maxWait = isAgent ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
let delay = interval;
if (burstStart !== undefined) {
// Time already elapsed since the burst's first edit; the snapshot must fire
// no later than `maxWait` after that, so shrink the trailing delay to the
// remaining budget (never negative, so BullMQ fires it promptly).
const remaining = burstStart + maxWait - now;
delay = Math.max(0, Math.min(interval, remaining));
}
return { jobId: page.id, delay };
const pageAge = now - new Date(page.createdAt).getTime();
const delay = isAgent
? 0
: pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
const jobId = isAgent ? `${page.id}-agent` : page.id;
return { jobId, delay };
}
@Injectable()
@@ -143,11 +127,6 @@ export class PersistenceExtension implements Extension {
// coalescing window" per document and OR it across all edits in the window,
// so the snapshot is marked 'agent' regardless of who wrote last.
private agentTouched: Map<string, boolean> = new Map();
// #370 — epoch ms of the FIRST edit in the current idle-flush burst, per page.
// Set when the pending idle job is first armed (empty entry), read to enforce
// the max-wait ceiling in computeHistoryJob, and cleared when the idle job is
// consumed/cancelled so the next burst starts a fresh window.
private idleBurstStart: Map<string, number> = new Map();
// #251 — per-document "intentional clear pending" flags. Keyed by
// documentName, value = expiry timestamp (ms). Set by onStateless when the
// client reports a deliberate clear; consumed once by the next
@@ -347,19 +326,20 @@ export class PersistenceExtension implements Extension {
//this.logger.debug('Contributors error:' + err?.['message']);
}
// #370 — boundary snapshot on ANY source transition. When the store
// flips the page's provenance (user↔agent↔git), pin the OUTGOING
// state as its own history version BEFORE the incoming source
// overwrites it. `page` still holds the OLD content/provenance here,
// so saveHistory(page) captures the pre-transition state tagged with
// its own source, kind='boundary'. The incoming content is snapshotted
// later by the debounced idle job. Skip if the page is effectively
// empty or if the latest existing snapshot already equals this state
// (the shared isDeepStrictEqual gate — avoids duplicates). Generalizing
// beyond the old user→agent special-case also covers git-sync for free.
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds
// the OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
// if the prior state is already agent-authored (boundary already
// pinned on the user->agent transition), if the page is effectively
// empty, or if the latest existing snapshot already equals this human
// state (avoid duplicates).
if (
page.lastUpdatedSource &&
page.lastUpdatedSource !== lastUpdatedSource
lastUpdatedSource === 'agent' &&
page.lastUpdatedSource !== 'agent'
) {
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
@@ -367,13 +347,15 @@ export class PersistenceExtension implements Extension {
page.id,
{ includeContent: true, trx },
);
const baselineMissing =
const humanBaselineMissing =
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && baselineMissing) {
if (
!isEmptyParagraphDoc(page.content as any) &&
humanBaselineMissing
) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
kind: 'boundary',
trx,
});
}
@@ -498,14 +480,6 @@ export class PersistenceExtension implements Extension {
return; // unrelated / malformed stateless message
}
// #370 — explicit "save a version" (human Cmd+S / agent save tool). Edit
// rights are already enforced by the readOnly reject above (a reader can't
// create a version), exactly as intentional-clear requires.
if (message?.type === SAVE_VERSION_MESSAGE_TYPE) {
await this.handleSaveVersion(data);
return;
}
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
this.intentionalClear.set(
@@ -514,117 +488,6 @@ export class PersistenceExtension implements Extension {
);
}
/**
* #370 persist an intentional version from the live in-memory ydoc.
*
* One stateless path serves BOTH the human and the agent; the tier is derived
* SERVER-SIDE from the signed connection actor ('agent' 'agent', anything
* else 'manual'), so the version type cannot be spoofed by the client. We
* take the fresh ydoc from the collab process memory and run it through the
* EXISTING store path first (so pages.content/ydoc reflect the exact content
* being versioned a REST endpoint would race the up-to-10s-stale page row),
* then snapshot it into page_history with the intentional kind.
*
* Promote-not-dup: if the latest history row already holds this exact content
* and it is an autosave (idle/boundary/legacy-null), upgrade its kind in place
* instead of duplicating a heavy content row; if it is already 'manual', it is
* a no-op (the client shows an "already saved" toast). Otherwise a fresh
* version row is written, popping the aggregated contributors from Redis.
*/
private async handleSaveVersion(data: onStatelessPayload): Promise<void> {
const { connection, document, documentName } = data;
const context = connection?.context;
const pageId = getPageId(documentName);
// Unforgeable: 'agent' only for a signed agent connection, else 'manual'.
const kind: PageHistoryKind =
context?.actor === 'agent' ? 'agent' : 'manual';
// Flush the live ydoc through the normal store path so the page row + ydoc
// hold exactly what we are about to version (also fires the idle enqueue we
// supersede below, plus any source-transition boundary). onStoreDocument
// only needs document/documentName/context.
await this.onStoreDocument({
document,
documentName,
context,
} as onStoreDocumentPayload);
let result:
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
| undefined;
await executeTx(this.db, async (trx) => {
const page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
if (!page) return;
// Never version an effectively-empty page (mirrors the processor's
// first-history guard); there is nothing intentional to pin.
if (isEmptyParagraphDoc(page.content as any)) return;
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
page.id,
{ includeContent: true, trx },
);
if (
lastHistory &&
isDeepStrictEqual(lastHistory.content, page.content)
) {
// Content is already snapshotted. Promote-not-dup.
if (lastHistory.kind === 'manual') {
result = {
historyId: lastHistory.id,
kind: 'manual',
alreadySaved: true,
};
return;
}
await this.pageHistoryRepo.updateHistoryKind(
lastHistory.id,
kind,
trx,
);
result = { historyId: lastHistory.id, kind, alreadySaved: false };
return;
}
// Fresh version row. Pop the contributors aggregated since the last
// snapshot (SPOP); restore them if the write fails so they aren't lost.
const contributorIds = await this.collabHistory.popContributors(page.id);
try {
const saved = await this.pageHistoryRepo.saveHistory(page, {
contributorIds,
kind,
trx,
});
result = { historyId: saved.id, kind, alreadySaved: false };
} catch (err) {
await this.collabHistory.addContributors(page.id, contributorIds);
throw err;
}
});
// Housekeeping: this explicit version supersedes the page's pending idle
// autosnapshot, so cancel it (delayed job → remove() just deletes it) and
// end the current idle burst so the next edit starts a fresh max-wait window.
await this.historyQueue.remove(pageId).catch(() => undefined);
this.idleBurstStart.delete(pageId);
if (result) {
document.broadcastStateless(
JSON.stringify({
type: 'version.saved',
historyId: result.historyId,
kind: result.kind,
alreadySaved: result.alreadySaved,
}),
);
}
}
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user?.id;
@@ -682,45 +545,17 @@ export class PersistenceExtension implements Extension {
page: Page,
lastUpdatedSource: string,
): Promise<void> {
// #370 — trailing idle debounce with a max-wait ceiling. One pending idle
// job per page (jobId = page.id); on every store we remove the pending
// delayed job and re-add it, so the snapshot lands `delay` after edits go
// quiet rather than once per store (precedent: workspace.service.ts).
// remove() on a delayed job simply deletes it (0 if absent, no throw); if the
// job is already ACTIVE and the remove is a no-op, the add still de-dups and
// the processor's isDeepStrictEqual gate collapses the duplicate content.
//
// The FIRST arm of a burst records `burstStart`; computeHistoryJob shrinks
// the delay to the remaining max-wait budget from that point, so a continuous
// session cannot re-arm the trailing timer forever and starve the snapshot.
// A burst marker older than THIS TIER's max-wait means the previous idle job
// has already fired — start a fresh window instead of firing immediately on
// the next edit. Must use the SAME source-specific max-wait computeHistoryJob
// uses (agent 5m / user 10m): a hardcoded USER ceiling would leave an agent
// burst's marker stale for 5..10m, forcing delay=0 on every store in that
// window and writing one idle row per store — exactly the per-store bloat the
// debounce exists to prevent, on the continuous-agent path.
const maxWait =
lastUpdatedSource === 'agent' ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
const now = Date.now();
let burstStart = this.idleBurstStart.get(page.id);
if (burstStart === undefined || now - burstStart >= maxWait) {
burstStart = now;
this.idleBurstStart.set(page.id, burstStart);
}
// Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
// doc comment for the agent-delay-0 / age-based-debounce invariants).
const { jobId, delay } = computeHistoryJob(
page,
lastUpdatedSource,
burstStart,
now,
Date.now(),
);
await this.historyQueue.remove(jobId).catch(() => undefined);
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id, kind: 'idle' } as IPageHistoryJob,
{ pageId: page.id } as IPageHistoryJob,
{ jobId, delay },
);
}
@@ -66,15 +66,6 @@ describe('HistoryProcessor.process', () => {
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
// #370 F3 — the processor now serializes its find+save under a page-row lock
// via executeTx. A db whose transaction().execute(fn) runs fn with a trx stub
// drives the real executeTx() helper without a database.
const db = {
transaction: () => ({
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
}),
};
// WorkerHost's constructor reads `this.worker`; passing repos positionally
// matches the constructor and avoids the Nest DI container.
proc = new HistoryProcessor(
@@ -82,7 +73,6 @@ describe('HistoryProcessor.process', () => {
pageRepo as any,
collabHistory as any,
watcherService as any,
db as any,
notificationQueue as any,
generalQueue as any,
);
@@ -136,26 +126,15 @@ describe('HistoryProcessor.process', () => {
await proc.process(buildJob());
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
// #370 F3/F9 — the snapshot decision runs under a page-row lock. Pin the lock
// structurally so a refactor that drops withLock/trx (silently reintroducing
// the TOCTOU double-insert) turns this red. The tx stub is { __trx: true }.
expect(pageRepo.findById).toHaveBeenCalledWith(
PAGE_ID,
expect.objectContaining({ withLock: true, trx: { __trx: true } }),
);
// #370 F7 — addPageWatchers MUST receive the trx, or its FK-check runs on a
// separate connection and self-deadlocks against our FOR UPDATE. Asserting
// the trx arg here is exactly what would have caught that regression.
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
['u1', 'u2'],
PAGE_ID,
SPACE_ID,
WORKSPACE_ID,
{ __trx: true },
);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
expect.objectContaining({ id: PAGE_ID }),
{ contributorIds: ['u1', 'u2'], kind: 'idle', trx: { __trx: true } },
{ contributorIds: ['u1', 'u2'] },
);
expect(generalQueue.add).toHaveBeenCalledWith(
QueueJob.PAGE_BACKLINKS,
@@ -207,48 +186,6 @@ describe('HistoryProcessor.process', () => {
]);
});
it('COMMIT failure (throw outside the tx callback) → contributors RESTORED', async () => {
// #370 F8 — a commit-time failure throws OUTSIDE the callback, so the inner
// try/catch does not run; the outer catch must restore the popped set (else a
// BullMQ retry writes an unattributed version). Use a db whose execute() runs
// the callback THEN throws, simulating a commit abort.
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
});
const commitFail = {
transaction: () => ({
execute: async (fn: (trx: any) => Promise<any>) => {
await fn({ __trx: true }); // callback succeeds (saveHistory ok)
throw new Error('commit aborted'); // ...but the COMMIT fails
},
}),
};
const procCommitFail = new HistoryProcessor(
pageHistoryRepo as any,
pageRepo as any,
collabHistory as any,
watcherService as any,
commitFail as any,
notificationQueue as any,
generalQueue as any,
);
jest
.spyOn(procCommitFail['logger'], 'error')
.mockImplementation(() => undefined);
await expect(procCommitFail.process(buildJob())).rejects.toThrow(
'commit aborted',
);
// The inner catch did NOT run (save succeeded), so only the outer catch can
// restore — assert it did.
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
'u1',
'u2',
]);
// And the post-snapshot queue work must NOT have run (we rethrew).
expect(generalQueue.add).not.toHaveBeenCalled();
});
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
@@ -19,9 +19,6 @@ import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
import { isEmptyParagraphDoc } from '../collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -32,7 +29,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {
@@ -45,9 +41,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
try {
const { pageId } = job.data;
// Read the page WITHOUT a lock first, only to bail early on the two cheap
// no-write cases (page gone / empty first snapshot) without opening a
// transaction. The authoritative check-then-write happens locked below.
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
@@ -58,109 +51,40 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
return;
}
// #370 F3 — the snapshot decision (findPageLastHistory → saveHistory) must
// be serialized against manual-save/boundary writers, which run under a
// page-row lock in onStoreDocument. Without it, this processor and a
// concurrent manual-save each read the same lastHistory (MVCC), both see
// content != lastHistory, and both insert — producing two page_history rows
// with IDENTICAL content (one 'idle', one 'manual'), defeating
// promote-not-dup and the version-vs-autosave split. Taking the same
// page-row lock makes the second writer observe the first's committed row so
// the isDeepStrictEqual gate collapses the duplicate. Only the read+write
// is transacted; the post-snapshot queue work stays outside.
let contributorIds: string[] = [];
let snapshotWritten = false;
let lastHistoryContent: unknown;
// #370 F8 — the contributor set popped from Redis (destructive SPOP) must be
// restored if the snapshot does not durably land. The inner try/catch only
// covers a throw INSIDE the callback; a COMMIT failure (connection drop,
// serialization/deadlock abort on commit — the transient class the epic
// already retries) throws OUTSIDE it, rolling the snapshot back while the
// pop is already gone. We track the popped set here and restore it in the
// outer catch so a BullMQ retry re-attributes the version. addContributors
// is an idempotent Redis SADD, so a double-restore is harmless.
let poppedForRestore: string[] = [];
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
try {
await executeTx(this.db, async (trx) => {
const lockedPage = await this.pageRepo.findById(pageId, {
includeContent: true,
withLock: true,
trx,
});
if (!lockedPage) return;
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
lastHistoryContent = lastHistory?.content;
if (!lastHistory && isEmptyParagraphDoc(lockedPage.content as any)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
return;
}
if (
lastHistory &&
isDeepStrictEqual(lastHistory.content, lockedPage.content)
) {
return; // already snapshotted at this content — nothing to write
}
contributorIds = await this.collabHistory.popContributors(pageId);
poppedForRestore = contributorIds;
try {
// Pass `trx` so the watcher insert's FK check (FOR KEY SHARE on
// pages[pageId]) runs on the SAME connection that already holds the
// FOR UPDATE lock from findById — otherwise it takes the FK lock on a
// separate pool connection and self-deadlocks against our own tx.
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
lockedPage.spaceId,
lockedPage.workspaceId,
trx,
);
// #370 — every job on this queue is a trailing idle-flush autosnapshot.
await this.pageHistoryRepo.saveHistory(lockedPage, {
contributorIds,
kind: job.data.kind ?? 'idle',
trx,
});
snapshotWritten = true;
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(pageId, contributorIds);
poppedForRestore = [];
throw err;
}
});
} catch (err) {
// A throw here means the tx did NOT commit (callback threw, or the commit
// itself failed and rolled back). If we popped contributors and the inner
// catch did not already restore them, restore now so the retry keeps
// attribution. snapshotWritten is irrelevant: it is set before commit, so
// it can be true even when the commit rolled the snapshot back.
if (poppedForRestore.length) {
await this.collabHistory.addContributors(pageId, poppedForRestore);
}
throw err;
}
// No snapshot written (page vanished / empty-first / unchanged content) →
// clear the contributor set for the skip cases and stop.
if (!snapshotWritten) {
if (!lastHistoryContent && isEmptyParagraphDoc(page.content as any)) {
await this.collabHistory.clearContributors(pageId);
}
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
await this.collabHistory.clearContributors(pageId);
return;
}
{
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds = await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
page.spaceId,
page.workspaceId,
);
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(pageId, contributorIds);
throw err;
}
const mentions = extractMentions(page.content);
const pageMentions = extractPageMentions(mentions);
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
@@ -178,7 +102,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
);
});
if (contributorIds.length > 0 && lastHistoryContent) {
if (contributorIds.length > 0 && lastHistory?.content) {
await this.notificationQueue
.add(QueueJob.PAGE_UPDATED, {
pageId,
@@ -1,27 +0,0 @@
import { type Kysely } from 'kysely';
/**
* #370 page-versioning intentionality tier on a history snapshot.
*
* Adds `page_history.kind`, the three-tier "how intentional was this snapshot"
* marker that lets versions (intentional points) be told apart from autosaves:
* - 'manual' a human explicitly saved a version (Cmd+S / Save button)
* - 'agent' the AI agent explicitly saved a version
* - 'idle' trailing idle-flush autosnapshot (safety net)
* - 'boundary' autosnapshot pinned on a source transition (useragentgit)
*
* Nullable with NO default (mirrors last_updated_source in the agent-provenance
* migration): legacy rows predate the marker and read back as `null`, which the
* client renders as a plain autosave. Stored as a short varchar to stay
* forward-compatible without an enum migration.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('kind', 'varchar(20)', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('page_history').dropColumn('kind').execute();
}
@@ -13,7 +13,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { resolveAgentProvenance } from '../agent-provenance';
import { PageHistoryKind } from '../../../collaboration/constants';
/**
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
@@ -47,9 +46,6 @@ export class PageHistoryRepo {
'lastUpdatedById',
'lastUpdatedSource',
'lastUpdatedAiChatId',
// #370 — intentionality tier ('manual' | 'agent' | 'idle' | 'boundary');
// null on legacy rows (= autosave). Selected so callers can read/promote it.
'kind',
'contributorIds',
'spaceId',
'workspaceId',
@@ -89,15 +85,9 @@ export class PageHistoryRepo {
async saveHistory(
page: Page,
opts?: {
contributorIds?: string[];
// #370 — intentionality tier for this snapshot. Omitted → null (legacy
// autosave semantics). Callers derive it server-side, never from a client.
kind?: PageHistoryKind;
trx?: KyselyTransaction;
},
): Promise<PageHistory> {
return await this.insertPageHistory(
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
await this.insertPageHistory(
{
pageId: page.id,
slugId: page.slugId,
@@ -109,7 +99,6 @@ export class PageHistoryRepo {
// Copy the provenance marker off the page row, as for lastUpdatedById.
lastUpdatedSource: page.lastUpdatedSource,
lastUpdatedAiChatId: page.lastUpdatedAiChatId,
kind: opts?.kind ?? null,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
@@ -118,25 +107,6 @@ export class PageHistoryRepo {
);
}
/**
* #370 promote an existing snapshot's intentionality tier in place. Used by
* the manual-save "promote-not-dup" path: when the latest history row already
* holds the exact content being versioned, we upgrade its `kind` instead of
* duplicating a heavy content row.
*/
async updateHistoryKind(
pageHistoryId: string,
kind: PageHistoryKind,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('pageHistory')
.set({ kind })
.where('id', '=', pageHistoryId)
.execute();
}
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pageHistory')
-1
View File
@@ -280,7 +280,6 @@ export interface PageHistory {
createdAt: Generated<Timestamp>;
icon: string | null;
id: Generated<string>;
kind: string | null;
lastUpdatedAiChatId: string | null;
lastUpdatedById: string | null;
lastUpdatedSource: string | null;
@@ -20,10 +20,6 @@ export interface IStripeSeatsSyncJob {
export interface IPageHistoryJob {
pageId: string;
// #370 — intentionality tier the worker stamps on the snapshot. All jobs on
// this queue are trailing idle-flush autosnapshots, so this is 'idle' (absent
// → treated as 'idle' by the processor).
kind?: 'idle';
}
/**
+1
View File
@@ -20,6 +20,7 @@ export * from "./lib/html-embed/html-embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/multi-cursor";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/transclusion";
@@ -0,0 +1,3 @@
import { MultiCursor } from "./multi-cursor";
export * from "./multi-cursor";
export default MultiCursor;
@@ -0,0 +1,453 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Bold } from "@tiptap/extension-bold";
import { Node as PMNode } from "@tiptap/pm/model";
import { MultiCursor, multiCursorPluginKey, MAX_CURSORS } from "./multi-cursor";
import { findOccurrences } from "../search-and-replace/find-occurrences";
const extensions = [Document, Paragraph, Text, Bold, MultiCursor];
function makeEditor(content?: any) {
return new Editor({
extensions,
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
});
}
function doc(...paragraphs: string[]) {
return {
type: "doc",
content: paragraphs.map((text) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
})),
};
}
function paraTexts(d: PMNode): string[] {
const out: string[] = [];
d.forEach((node) => {
if (node.type.name === "paragraph") out.push(node.textContent);
});
return out;
}
function cursors(editor: Editor) {
return multiCursorPluginKey.getState(editor.state)!.cursors;
}
// Simulate typing a character through the real handleTextInput routing (the
// browser path). someMethod-equivalent: dispatch a DOM-ish text input by calling
// the view's input handler directly.
function typeText(editor: Editor, text: string) {
const { from, to } = editor.state.selection;
// props.handleTextInput is what ProseMirror calls on beforeinput/keypress.
const handled = editor.view.someProp(
"handleTextInput",
(fn) => fn(editor.view, from, to, text) || false,
);
if (!handled) {
// Fall back to a normal insertion (no active multi-cursor set).
editor.view.dispatch(editor.state.tr.insertText(text, from, to));
}
}
function pressKey(editor: Editor, key: string) {
editor.view.someProp("handleKeyDown", (fn) =>
fn(editor.view, new KeyboardEvent("keydown", { key })),
);
}
describe("multi-cursor: selectAllOccurrences", () => {
it("finds EVERY occurrence of a repeated word under the cursor", () => {
const editor = makeEditor(doc("foo bar foo baz foo"));
// Cursor inside the first "foo".
editor.commands.setTextSelection(2);
expect(editor.commands.selectAllOccurrences()).toBe(true);
const cs = cursors(editor);
expect(cs.length).toBe(3);
// Every cursor spans a "foo".
for (const c of cs) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
}
editor.destroy();
});
it("uses the current non-empty selection as the term", () => {
const editor = makeEditor(doc("ab abc ab abcd ab"));
// Select the first "ab".
editor.commands.setTextSelection({ from: 1, to: 3 });
expect(editor.state.doc.textBetween(1, 3)).toBe("ab");
editor.commands.selectAllOccurrences();
// Literal substring match (selection is not whole-word), so every "ab"
// including those inside "abc"/"abcd" is matched: 5 total.
const cs = cursors(editor);
expect(cs.length).toBe(5);
editor.destroy();
});
it("whole-word matching from a word cursor does not match substrings", () => {
const editor = makeEditor(doc("cat category cat scatter cat"));
editor.commands.setTextSelection(2); // inside first "cat"
editor.commands.selectAllOccurrences();
// Only the three standalone "cat" words, not "category"/"scatter".
expect(cursors(editor).length).toBe(3);
editor.destroy();
});
});
describe("multi-cursor: mass typing (single transaction)", () => {
it("types text into N carets at once", () => {
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(3);
// Typing replaces each selected "foo" with "X".
typeText(editor, "X");
expect(paraTexts(editor.state.doc)).toEqual(["X X X"]);
// The cursors are now carets right after each inserted "X".
const cs = cursors(editor);
expect(cs.length).toBe(3);
for (const c of cs) expect(c.from).toBe(c.to);
editor.destroy();
});
it("continues typing at the resulting carets (append semantics)", () => {
const editor = makeEditor(doc("a a a"));
editor.commands.setTextSelection(1);
editor.commands.selectAllOccurrences();
typeText(editor, "b"); // each "a" -> "b"
typeText(editor, "c"); // append at each caret -> "bc"
expect(paraTexts(editor.state.doc)).toEqual(["bc bc bc"]);
editor.destroy();
});
it("applies the whole multi-edit in a SINGLE transaction (one undo step)", () => {
// "One Cmd/Ctrl+Z undoes the whole multi-edit" holds iff the N edits land in
// ONE transaction (history groups by transaction). @tiptap/extension-history
// is not a dependency here, so rather than exercise undo we assert the
// property that guarantees it: typing into N cursors is exactly ONE dispatch.
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(3);
const orig = editor.view.dispatch.bind(editor.view);
let dispatches = 0;
editor.view.dispatch = (tr) => {
dispatches += 1;
return orig(tr);
};
typeText(editor, "Z");
editor.view.dispatch = orig;
expect(dispatches).toBe(1); // all three edits share one transaction
expect(paraTexts(editor.state.doc)).toEqual(["Z Z Z"]);
editor.destroy();
});
it("off-by-one guard: reverse-order iteration keeps every position valid", () => {
// If the mass edit iterated FORWARD, inserting at an earlier cursor would
// shift every later cursor and corrupt the result. Different-length
// replacement makes such a bug visible.
const editor = makeEditor(doc("x x x x"));
editor.commands.setTextSelection(1);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(4);
typeText(editor, "LONG");
expect(paraTexts(editor.state.doc)).toEqual(["LONG LONG LONG LONG"]);
editor.destroy();
});
});
describe("multi-cursor: mass Backspace / Delete", () => {
it("Backspace removes one char before each caret", () => {
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
// Collapse selections to carets at the END of each "foo" by typing then
// removing is complex; instead type to convert ranges into carets first.
typeText(editor, "ab"); // each "foo" -> "ab", carets after "ab"
expect(paraTexts(editor.state.doc)).toEqual(["ab ab ab"]);
pressKey(editor, "Backspace"); // remove the trailing "b" at each caret
expect(paraTexts(editor.state.doc)).toEqual(["a a a"]);
editor.destroy();
});
it("Delete removes one char after each caret", () => {
const editor = makeEditor(doc("fooX fooX"));
// Literal (selection) match of "foo" -> both occurrences inside "fooX".
editor.commands.setTextSelection({ from: 1, to: 4 }); // first "foo"
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
typeText(editor, "foo"); // rewrite "foo", carets now sit before each "X"
expect(paraTexts(editor.state.doc)).toEqual(["fooX fooX"]);
pressKey(editor, "Delete"); // remove the "X" after each caret
expect(paraTexts(editor.state.doc)).toEqual(["foo foo"]);
editor.destroy();
});
it("Backspace at a block-start caret is a no-op for that cursor", () => {
const editor = makeEditor(doc("ab", "ab"));
// Select both "ab" then convert to carets at start by replacing with "".
editor.commands.setTextSelection({ from: 1, to: 3 }); // first "ab"
editor.commands.selectAllOccurrences();
// Move carets to block start: type "" is not possible; instead delete range.
pressKey(editor, "Backspace"); // deletes each selected "ab"
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
// Carets are now at each block start; another Backspace must not throw and
// must not merge blocks (still two empty paragraphs).
pressKey(editor, "Backspace");
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
editor.destroy();
});
});
describe("multi-cursor: addNextOccurrence (Cmd/Ctrl+D)", () => {
it("first press selects the current word, next press adds the next", () => {
const editor = makeEditor(doc("go go go"));
editor.commands.setTextSelection(2); // inside first "go"
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(1);
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(2);
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(3);
// Nothing left to add — stays at 3.
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(3);
for (const c of cursors(editor)) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("go");
}
editor.destroy();
});
});
describe("multi-cursor: position remapping", () => {
it("remaps cursors after a LOCAL edit before them", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
// Insert unrelated text at the very start (pos 1), shifting everything +5.
editor.view.dispatch(editor.state.tr.insertText("HELLO", 1));
const after = cursors(editor);
expect(after.length).toBe(before.length);
for (let i = 0; i < after.length; i += 1) {
expect(after[i].from).toBe(before[i].from + 5);
expect(after[i].to).toBe(before[i].to + 5);
// And they still point at "foo".
expect(editor.state.doc.textBetween(after[i].from, after[i].to)).toBe(
"foo",
);
}
editor.destroy();
});
it("remaps cursors after a simulated REMOTE edit (ordinary transaction)", () => {
const editor = makeEditor(doc("foo bar foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
expect(before.length).toBe(2);
// y-prosemirror applies remote changes as ordinary transactions. Emulate a
// remote insertion between the two "foo"s (inside "bar", pos 6) with a tr
// that carries NO multi-cursor meta — exactly like a collaborator's edit.
const tr = editor.state.tr.insertText("ZZ", 6);
editor.view.dispatch(tr);
const after = cursors(editor);
// The first "foo" (before the insertion) is unchanged; the second shifts +2.
expect(after[0].from).toBe(before[0].from);
expect(after[1].from).toBe(before[1].from + 2);
for (const c of after) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
}
editor.destroy();
});
it("a REMOTE delete UNDER a cursor collapses it to a caret (not drop), leaving others intact", () => {
// The riskiest remap path: a collaborator deletes the very text one cursor
// spans. Both edges map with assoc +1 and there is no drop logic, so the
// deleted-over cursor CONTRACT is: it collapses to a zero-width caret at the
// deletion point (from === to) and STAYS in the set — it is not removed.
// Untouched cursors keep spanning their occurrence. Pinning this makes the
// collapse-not-drop choice explicit (review #372 F2).
const editor = makeEditor(doc("foo bar foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
expect(before.length).toBe(2);
// Remote (no multi-cursor meta) delete of the FIRST "foo" range.
const tr = editor.state.tr.delete(before[0].from, before[0].to);
editor.view.dispatch(tr);
const after = cursors(editor);
// Still two cursors — the deleted-over one is NOT dropped.
expect(after.length).toBe(2);
// The first collapsed to a caret at the deletion point.
expect(after[0].from).toBe(after[0].to);
expect(after[0].from).toBe(before[0].from);
// The second still spans "foo" (shifted left by the 3 removed chars).
expect(after[1].from).toBe(before[1].from - 3);
expect(editor.state.doc.textBetween(after[1].from, after[1].to)).toBe("foo");
// Sanity: the document now reads " bar foo".
expect(paraTexts(editor.state.doc)).toEqual([" bar foo"]);
editor.destroy();
});
});
describe("multi-cursor: collapse / exit", () => {
it("exitMultiCursor clears the set", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
editor.commands.exitMultiCursor();
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
it("an arrow key collapses the set", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
pressKey(editor, "ArrowRight");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
});
describe("multi-cursor: collapse on composition / mousedown", () => {
// Invoke a plugin handleDOMEvents handler through the real prop plumbing.
function fireDOM(editor: Editor, name: string): void {
editor.view.someProp("handleDOMEvents", (handlers: any) => {
const h = handlers && handlers[name];
if (h) h(editor.view, new Event(name));
return false;
});
}
it("collapses the set on compositionstart (IME) — MVP does not multi-IME", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
fireDOM(editor, "compositionstart");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
it("collapses the set on a plain mousedown (VS Code behaviour)", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
fireDOM(editor, "mousedown");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
});
describe("multi-cursor: hard cap", () => {
it("never activates more than MAX_CURSORS cursors", () => {
const many = new Array(MAX_CURSORS + 20).fill("w").join(" ");
const editor = makeEditor(doc(many));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(MAX_CURSORS);
editor.destroy();
});
});
describe("multi-cursor: marks are carried across a mass edit", () => {
it("preserves marks spanning each replaced range", () => {
const editor = makeEditor({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "a " },
{ type: "text", marks: [{ type: "bold" }], text: "key" },
{ type: "text", text: " b " },
{ type: "text", marks: [{ type: "bold" }], text: "key" },
],
},
],
});
editor.commands.setTextSelection(3); // inside first bold "key"
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
typeText(editor, "NEW");
// Both replacements keep the bold mark.
let boldRuns = 0;
editor.state.doc.descendants((node) => {
if (
node.isText &&
node.text === "NEW" &&
node.marks.some((m) => m.type.name === "bold")
) {
boldRuns += 1;
}
});
expect(boldRuns).toBe(2);
editor.destroy();
});
});
// The extracted find-occurrences util must return the SAME occurrences that the
// old inline walk produced (and that search-and-replace still relies on).
describe("find-occurrences util", () => {
it("finds all matches of a literal regex across text nodes", () => {
const editor = makeEditor(doc("foo foofoo foo"));
const results = findOccurrences(editor.state.doc, /foo/gu);
// 4 occurrences: two standalone + two inside "foofoo".
expect(results.length).toBe(4);
for (const r of results) {
expect(editor.state.doc.textBetween(r.from, r.to)).toBe("foo");
}
editor.destroy();
});
it("ignores whitespace-only matches and empty regex", () => {
const editor = makeEditor(doc("a b c"));
expect(findOccurrences(editor.state.doc, null as any).length).toBe(0);
// A whitespace regex yields no results (matches are trimmed away).
expect(findOccurrences(editor.state.doc, /\s/gu).length).toBe(0);
editor.destroy();
});
it("finds a match spanning two differently-marked contiguous text nodes", () => {
const editor = makeEditor({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "wo" },
{ type: "text", marks: [{ type: "bold" }], text: "rd" },
],
},
],
});
const results = findOccurrences(editor.state.doc, /word/gu);
expect(results.length).toBe(1);
expect(editor.state.doc.textBetween(results[0].from, results[0].to)).toBe(
"word",
);
editor.destroy();
});
});
@@ -0,0 +1,545 @@
import { Extension, Range } from "@tiptap/core";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
TextSelection,
type EditorState,
} from "@tiptap/pm/state";
import { Mark } from "@tiptap/pm/model";
import { findOccurrences } from "../search-and-replace/find-occurrences";
/**
* Multi-cursor editing MVP (issue #196, "Variant A").
*
* VS Code-style multi-cursor limited to "select all occurrences of a word (or
* the current selection) and type into all of them at once", built ON TOP OF
* the search-and-replace mass-transaction machinery:
*
* - Cmd/Ctrl+Shift+L (selectAllOccurrences): the word under the cursor (or the
* current non-empty selection) -> ALL its occurrences become active cursors.
* - Cmd/Ctrl+D (addNextOccurrence): add the NEXT occurrence of the term.
* - Typing / Backspace / Delete apply to EVERY active cursor in ONE
* transaction (so a single Cmd/Ctrl+Z undoes the whole multi-edit).
* - Esc (exitMultiCursor): collapse back to a single cursor.
*
* The single-transaction, reverse-order edit mechanic mirrors `replaceAll` in
* search-and-replace.ts: we iterate cursors from the END of the document to the
* START so an earlier edit never invalidates a later position, carrying the
* marks that span each range.
*
* CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (these are "Variant B", deliberately NOT
* built here):
* - Alt+Click arbitrary carets and Alt+drag column selection.
* - Cmd/Ctrl+Alt+Up/Down "add cursor on the adjacent line".
* - Simultaneous IME / composition input into multiple positions on
* `compositionstart` we collapse back to a single cursor.
* - Cursors spanning different schema nodes in one edit.
*
* NOT out of scope, but worth stating precisely: there is NO schema-aware or
* structural cursor. Occurrences are found by a plain text-node walk
* (`findOccurrences`), so a term that appears inside a table cell, code block or
* callout DOES get a cursor there and IS edited as plain text, exactly like
* `replaceAll`. There is no special table/code handling; the per-cursor try/catch
* only SKIPS a cursor whose edit would violate the schema (never applied
* half-way), it does not exclude those node types from matching.
*/
interface MultiCursorState {
// Each active cursor: a caret when from === to, a range when from < to.
cursors: Range[];
}
export const multiCursorPluginKey = new PluginKey<MultiCursorState>(
"multiCursor",
);
// Hard safety cap on simultaneously-active cursors — stop adding past it.
export const MAX_CURSORS = 100;
export interface MultiCursorStorage {
// Whether the active term matches whole words only. Set to true when the set
// was seeded from a bare cursor (word under caret), false when seeded from an
// explicit selection (literal substring match, like VS Code). Remembered so
// addNextOccurrence keeps matching the same way as selectAllOccurrences.
wholeWord: boolean;
}
declare module "@tiptap/core" {
interface Storage {
multiCursor: MultiCursorStorage;
}
interface Commands<ReturnType> {
multiCursor: {
/** Select all occurrences of the word/selection as active cursors. */
selectAllOccurrences: () => ReturnType;
/** Add the next occurrence of the current term to the cursor set. */
addNextOccurrence: () => ReturnType;
/** Collapse the multi-cursor set back to a single cursor. */
exitMultiCursor: () => ReturnType;
};
}
}
// ---------------------------------------------------------------------------
// Term helpers
// ---------------------------------------------------------------------------
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// A "word" is a run of letters/numbers/underscore; those get whole-word
// matching (\b…\b) so a term never matches inside a larger word. Anything else
// (punctuation, phrases) is matched literally. Case-sensitive, like VS Code.
function isWordTerm(s: string): boolean {
return /^[\p{L}\p{N}_]+$/u.test(s);
}
// wholeWord uses \b…\b so the term never matches inside a larger word; it only
// applies to word-like terms (a term containing punctuation cannot be
// whole-word-bounded meaningfully). Otherwise the term is matched literally.
function buildTermRegex(term: string, wholeWord: boolean): RegExp {
const esc = escapeRegExp(term);
return wholeWord && isWordTerm(term)
? new RegExp(`\\b${esc}\\b`, "gu")
: new RegExp(esc, "gu");
}
// Word under a position: returns the exact { from, to } range and its text, or
// null if the position is not inside a word in a textblock.
function getWordAt(
state: EditorState,
pos: number,
): { from: number; to: number; text: string } | null {
const $pos = state.doc.resolve(pos);
const parent = $pos.parent;
if (!parent.isTextblock) return null;
const text = parent.textContent;
const offset = $pos.parentOffset;
const start = $pos.start();
const wordRe = /[\p{L}\p{N}_]+/gu;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const s = m.index;
const e = m.index + m[0].length;
if (offset >= s && offset <= e) {
return { from: start + s, to: start + e, text: m[0] };
}
}
return null;
}
// ---------------------------------------------------------------------------
// Plugin-state access
// ---------------------------------------------------------------------------
function getCursors(state: EditorState): Range[] {
const st = multiCursorPluginKey.getState(state);
return st ? st.cursors : [];
}
function setCursors(view: EditorView, cursors: Range[]): void {
view.dispatch(view.state.tr.setMeta(multiCursorPluginKey, cursors));
}
function collapse(view: EditorView): void {
setCursors(view, []);
}
// ---------------------------------------------------------------------------
// The single-transaction, reverse-order mass edit (mirrors replaceAll)
// ---------------------------------------------------------------------------
interface EditOp {
from: number;
to: number;
// Text to insert at `from` after deleting [from, to); "" for a pure delete.
text: string;
}
/**
* Apply one edit per cursor in ONE transaction. Ops are processed from the END
* of the document to the START so an earlier edit never shifts a later position
* (mirrors `replaceAll`). Each cursor is wrapped independently: a schema
* violation SKIPS that one cursor instead of throwing away the whole
* transaction, so the document is never left half-applied.
*
* After building the transaction the new cursor positions are recomputed by
* mapping each op's original anchor through `tr.mapping` (which also remaps any
* concurrent changes), so carets land right after their inserted text.
*/
function dispatchMassEdit(view: EditorView, ops: EditOp[]): boolean {
if (!ops.length) return false;
const { state } = view;
const tr = state.tr;
const schema = state.schema;
// Ascending by `from`; iterate reverse so earlier positions stay valid.
const sorted = [...ops].sort((a, b) => a.from - b.from);
const appliedLen: number[] = new Array(sorted.length).fill(0);
for (let i = sorted.length - 1; i >= 0; i -= 1) {
const { from, to, text } = sorted[i];
try {
let marks: readonly Mark[] = [];
if (text) {
if (to > from) {
// Carry all marks spanning the replaced range.
const set = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mk) => set.add(mk));
}
});
marks = Array.from(set);
} else {
// Caret: continue the marks active at the insertion point.
marks = state.storedMarks || state.doc.resolve(from).marks();
}
}
// ONE atomic step per cursor: replaceWith covers both insert (from === to)
// and replace (to > from); a pure delete (empty text) uses delete. This
// can never leave a cursor half-applied (deleted but not re-inserted) the
// way a separate delete-then-insert pair could if the insert step threw.
if (text) {
tr.replaceWith(from, to, schema.text(text, marks as Mark[]));
} else if (to > from) {
tr.delete(from, to);
}
appliedLen[i] = text.length;
} catch {
// Per-cursor backstop (text-only MVP): drop this cursor's edit, keep the
// rest of the transaction intact.
appliedLen[i] = 0;
}
}
if (!tr.docChanged) return false;
// Recompute cursor carets from the ORIGINAL op anchors through the full map.
const newCursors: Range[] = sorted.map((op, i) => {
const start = tr.mapping.map(op.from, -1);
const caret = start + appliedLen[i];
return { from: caret, to: caret };
});
tr.setMeta(multiCursorPluginKey, newCursors);
// Park the native selection on the last caret so the browser draws exactly
// one real caret; the rest are our decoration widgets.
const last = newCursors[newCursors.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from));
view.dispatch(tr);
return true;
}
function buildDeleteOps(
state: EditorState,
cursors: Range[],
forward: boolean,
): EditOp[] {
return cursors.map((c) => {
// A selected range: Backspace/Delete removes the whole range.
if (c.to > c.from) return { from: c.from, to: c.to, text: "" };
const $pos = state.doc.resolve(c.from);
if (forward) {
// Delete: at the end of a textblock there is nothing to remove (a no-op;
// MVP does not merge blocks across a multi-cursor set).
if ($pos.parentOffset >= $pos.parent.content.size) {
return { from: c.from, to: c.from, text: "" };
}
return { from: c.from, to: c.from + 1, text: "" };
}
// Backspace: at the start of a textblock there is nothing to remove.
if ($pos.parentOffset <= 0) {
return { from: c.from, to: c.from, text: "" };
}
return { from: c.from - 1, to: c.from, text: "" };
});
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export const MultiCursor = Extension.create<unknown, MultiCursorStorage>({
name: "multiCursor",
addStorage() {
return { wholeWord: true };
},
addCommands() {
return {
selectAllOccurrences:
() =>
({ editor, state, tr, dispatch }) => {
let term: string;
// A bare cursor expands to the whole word; an explicit selection is
// matched literally (VS Code semantics).
const wholeWord = state.selection.empty;
if (wholeWord) {
const word = getWordAt(state, state.selection.from);
if (!word) return false;
term = word.text;
} else {
term = state.doc.textBetween(
state.selection.from,
state.selection.to,
);
}
if (!term.trim()) return false;
editor.storage.multiCursor.wholeWord = wholeWord;
const results = findOccurrences(
state.doc,
buildTermRegex(term, wholeWord),
).slice(0, MAX_CURSORS);
if (!results.length) return false;
if (dispatch) {
tr.setMeta(multiCursorPluginKey, results);
const last = results[results.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
dispatch(tr);
}
return true;
},
addNextOccurrence:
() =>
({ editor, state, tr, dispatch }) => {
const existing = getCursors(state);
let cursors: Range[];
if (!existing.length) {
// First press: turn the current word/selection into the one cursor.
let range: Range;
const wholeWord = state.selection.empty;
if (wholeWord) {
const word = getWordAt(state, state.selection.from);
if (!word) return false;
range = { from: word.from, to: word.to };
} else {
range = { from: state.selection.from, to: state.selection.to };
}
editor.storage.multiCursor.wholeWord = wholeWord;
cursors = [range];
} else {
// Subsequent press: add the next unselected occurrence of the term,
// matched the SAME way (whole-word vs literal) the set was seeded.
if (existing.length >= MAX_CURSORS) return true;
const first = existing[0];
const term = state.doc.textBetween(first.from, first.to);
if (!term.trim()) return false;
const results = findOccurrences(
state.doc,
buildTermRegex(term, editor.storage.multiCursor.wholeWord),
);
const keys = new Set(existing.map((c) => `${c.from}:${c.to}`));
const notSelected = results.filter(
(r) => !keys.has(`${r.from}:${r.to}`),
);
if (!notSelected.length) return true; // all occurrences selected
const maxTo = Math.max(...existing.map((c) => c.to));
const next =
notSelected.find((r) => r.from >= maxTo) || notSelected[0];
cursors = [...existing, next];
}
if (dispatch) {
tr.setMeta(multiCursorPluginKey, cursors);
const last = cursors[cursors.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
dispatch(tr);
}
return true;
},
exitMultiCursor:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(multiCursorPluginKey, []);
dispatch(tr);
}
return true;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => {
this.editor.commands.selectAllOccurrences();
// Always consume so the browser's default is prevented.
return true;
},
"Mod-d": () => {
this.editor.commands.addNextOccurrence();
// Consume unconditionally to prevent the browser's Cmd/Ctrl+D bookmark.
return true;
},
Escape: () => {
// Only swallow Escape while a multi-cursor set is active; otherwise let
// Escape keep its other behaviours (e.g. closing dialogs).
if (!getCursors(this.editor.state).length) return false;
return this.editor.commands.exitMultiCursor();
},
};
},
addProseMirrorPlugins() {
return [
new Plugin<MultiCursorState>({
key: multiCursorPluginKey,
state: {
init: () => ({ cursors: [] }),
apply(tr, value): MultiCursorState {
// A command (or a mass edit) can set/clear the cursor set directly.
// Its cursors are already in the post-transaction coordinate space,
// so they take priority over remapping.
const meta = tr.getMeta(multiCursorPluginKey) as
| Range[]
| undefined;
if (meta !== undefined) {
return { cursors: meta.slice(0, MAX_CURSORS) };
}
if (!value.cursors.length) return value;
// Remap surviving cursors across ANY doc change — this covers both
// local edits and REMOTE Yjs edits (y-prosemirror applies remote
// changes as ordinary transactions, so mapping them here keeps every
// multi-cursor correctly positioned without special-casing collab).
if (tr.docChanged) {
// Map both edges with the SAME association (+1) so content
// inserted at a boundary shifts the whole cursor right and a caret
// (from === to) can never invert into a range.
const cursors = value.cursors.map((c) => ({
from: tr.mapping.map(c.from, 1),
to: tr.mapping.map(c.to, 1),
}));
return { cursors };
}
return value;
},
},
props: {
decorations(state) {
const st = multiCursorPluginKey.getState(state);
if (!st || !st.cursors.length) return DecorationSet.empty;
const decorations: Decoration[] = [];
st.cursors.forEach((c, i) => {
if (c.from === c.to) {
decorations.push(
Decoration.widget(
c.from,
() => {
const el = document.createElement("span");
el.className = "multi-cursor__caret";
return el;
},
{ side: 0, key: `mc-caret-${i}` },
),
);
} else {
decorations.push(
Decoration.inline(c.from, c.to, {
class: "multi-cursor__selection",
}),
);
}
});
return DecorationSet.create(state.doc, decorations);
},
handleTextInput(view, _from, _to, text) {
const cursors = getCursors(view.state);
if (!cursors.length) return false;
// Insert `text` at EVERY cursor in one transaction. Returning true
// prevents ProseMirror's own single-position insert at the native
// selection, so there is no double-insert there.
const ops = cursors.map((c) => ({
from: c.from,
to: c.to,
text,
}));
return dispatchMassEdit(view, ops);
},
handleKeyDown(view, event) {
const cursors = getCursors(view.state);
if (!cursors.length) return false;
if (event.key === "Backspace") {
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, false));
return true;
}
if (event.key === "Delete") {
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, true));
return true;
}
// Let modifier combinations (our own shortcuts, copy, etc.) through
// WITHOUT collapsing the set.
if (event.metaKey || event.ctrlKey || event.altKey) return false;
// Navigation / block keys collapse back to a single cursor, then let
// ProseMirror handle the movement on the native selection.
const COLLAPSE_KEYS = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
"Enter",
"Tab",
];
if (COLLAPSE_KEYS.includes(event.key)) {
collapse(view);
return false;
}
return false;
},
handleDOMEvents: {
// A plain click exits multi-cursor (VS Code behaviour).
mousedown: (view) => {
if (getCursors(view.state).length) collapse(view);
return false;
},
// MVP does not drive multi-position IME — collapse on composition.
compositionstart: (view) => {
if (getCursors(view.state).length) collapse(view);
return false;
},
},
},
}),
];
},
});
export default MultiCursor;
@@ -0,0 +1,69 @@
import { Range } from "@tiptap/core";
import { Node as PMNode } from "@tiptap/pm/model";
interface TextNodesWithPosition {
text: string;
pos: number;
}
/**
* Shared "find all occurrences of a term in the doc" primitive.
*
* Walks every text node of the document and returns each regex match as a
* `{ from, to }` range. Contiguous text nodes (which may differ only by marks)
* are concatenated into a single run, so a match that spans e.g. "wo" + bold
* "rd" is still found; runs are split by any non-text node, so a match never
* crosses a node boundary. Whitespace-only matches are ignored.
*
* This is used by BOTH search-and-replace (highlight/replace) and multi-cursor
* (turn occurrences into active cursors) so the two stay behaviourally in sync.
* Extracted verbatim from the original `processSearches` walk.
*/
export function findOccurrences(doc: PMNode, searchTerm: RegExp): Range[] {
const results: Range[] = [];
if (!searchTerm) return results;
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
return results;
}
@@ -1,3 +1,4 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export * from './find-occurrences'
export default SearchAndReplace
@@ -29,6 +29,7 @@ import {
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
import { findOccurrences } from "./find-occurrences";
declare module "@tiptap/core" {
interface Storage {
@@ -76,11 +77,6 @@ declare module "@tiptap/core" {
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
s: string,
disableRegex: boolean,
@@ -104,10 +100,6 @@ function processSearches(
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
@@ -116,43 +108,8 @@ function processSearches(
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
// Shared find-all-occurrences primitive (also used by multi-cursor).
const results: Range[] = findOccurrences(doc, searchTerm);
for (let i = 0; i < results.length; i += 1) {
const r = results[i];