Title now lives in the page's Yjs 'title' fragment, but two paths corrupted it: - Rename-revert: a REST/MCP title change wrote only the page.title column, never the Yjs fragment, so the next editor open replayed the stale Yjs title and reverted the rename. PageService.update now mirrors the new title into the Yjs 'title' fragment via CollaborationGateway.writePageTitle, which goes through openDirectConnection directly (Redis-independent: works with COLLAB_DISABLE_REDIS and in single-process deployments, unlike the Redis-routed handleYjsEvent path). The write is best-effort: a Yjs failure is logged and never rolls back the committed column write. Agent provenance (actor/aiChatId) is threaded into the store context. - Untitled-on-open: an empty/just-initialized 'title' fragment clobbered a non-empty page.title to '' on open. onStoreDocument now treats the title as changed only when the extracted text is non-empty, covering both the title-only and body+title save branches. Empty-retitling via collab is intentionally impossible; the REST DTO is the place to enforce non-empty. writeTitleFragment does a full clear+seed of the 'title' fragment (no duplication/concatenation) and leaves the body fragment intact. Removed the dead useTreeMutation.handleRename path. Adds unit tests for writeTitleFragment, the gateway write, the anti-empty-clobber guard, and agent provenance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
9.8 KiB
TypeScript
261 lines
9.8 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useAtom, useStore } from "jotai";
|
|
import { notifications } from "@mantine/notifications";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
|
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
|
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
|
import { IPage } from "@/features/page/types/page.types.ts";
|
|
import {
|
|
useCreatePageMutation,
|
|
useRemovePageMutation,
|
|
useMovePageMutation,
|
|
updateCacheOnMovePage,
|
|
} from "@/features/page/queries/page-query.ts";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { getSpaceUrl } from "@/lib/config.ts";
|
|
|
|
export type UseTreeMutation = {
|
|
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
|
handleCreate: (
|
|
parentId: string | null,
|
|
opts?: { temporary?: boolean },
|
|
) => Promise<void>;
|
|
handleDelete: (id: string) => Promise<void>;
|
|
};
|
|
|
|
export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|
const { t } = useTranslation();
|
|
const [, setData] = useAtom(treeDataAtom);
|
|
// `store` reads the *current* treeDataAtom imperatively in handlers — avoids
|
|
// stale-closure issues when the caller updates the tree (e.g. lazy-load
|
|
// children) and then immediately invokes a handler.
|
|
const store = useStore();
|
|
const createPageMutation = useCreatePageMutation();
|
|
const removePageMutation = useRemovePageMutation();
|
|
const movePageMutation = useMovePageMutation();
|
|
const navigate = useNavigate();
|
|
const { spaceSlug, pageSlug } = useParams();
|
|
|
|
const handleMove = useCallback(
|
|
async (sourceId: string, op: DropOp) => {
|
|
const before = store.get(treeDataAtom);
|
|
const { tree: after } = treeModel.move(before, sourceId, op);
|
|
if (after === before) return;
|
|
|
|
const payload = dropOpToMovePayload(before, sourceId, op);
|
|
const source = treeModel.find(before, sourceId) as SpaceTreeNode | null;
|
|
if (!source) return;
|
|
const oldParentId = source.parentPageId ?? null;
|
|
|
|
// optimistic apply with the new position from the payload
|
|
let optimistic = treeModel.update(after, sourceId, {
|
|
position: payload.position,
|
|
parentPageId: payload.parentPageId,
|
|
} as Partial<SpaceTreeNode>);
|
|
|
|
// If the old parent has no children left, mark hasChildren: false so the
|
|
// chevron disappears. Without this, the empty parent keeps rendering an
|
|
// expand toggle that fetches zero rows on click.
|
|
if (oldParentId) {
|
|
const oldParent = treeModel.find(optimistic, oldParentId);
|
|
if (!oldParent?.children?.length) {
|
|
optimistic = treeModel.update(optimistic, oldParentId, {
|
|
hasChildren: false,
|
|
} as Partial<SpaceTreeNode>);
|
|
}
|
|
}
|
|
|
|
// For make-child onto a previously-childless target: flip hasChildren on
|
|
// so the new parent shows its chevron.
|
|
if (op.kind === "make-child") {
|
|
optimistic = treeModel.update(optimistic, op.targetId, {
|
|
hasChildren: true,
|
|
} as Partial<SpaceTreeNode>);
|
|
}
|
|
|
|
setData(optimistic);
|
|
|
|
try {
|
|
await movePageMutation.mutateAsync(payload);
|
|
} catch {
|
|
setData(before);
|
|
notifications.show({
|
|
message: t("Failed to move page"),
|
|
color: "red",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const pageData: Partial<IPage> = {
|
|
id: source.id,
|
|
slugId: source.slugId,
|
|
title: source.name,
|
|
icon: source.icon,
|
|
position: payload.position,
|
|
spaceId: source.spaceId,
|
|
parentPageId: payload.parentPageId,
|
|
hasChildren: source.hasChildren,
|
|
};
|
|
|
|
updateCacheOnMovePage(
|
|
spaceId,
|
|
sourceId,
|
|
oldParentId,
|
|
payload.parentPageId,
|
|
pageData,
|
|
);
|
|
|
|
// Realtime broadcast is now server-authoritative: the server emits
|
|
// `moveTreeNode` to the space room on PAGE_MOVED. The old client relay
|
|
// (emit + setTimeout(50)) was removed; the optimistic local update above
|
|
// stays for instant feedback to the author.
|
|
},
|
|
[setData, store, movePageMutation, spaceId, t],
|
|
);
|
|
|
|
const handleCreate = useCallback(
|
|
async (parentId: string | null, opts?: { temporary?: boolean }) => {
|
|
const payload: {
|
|
spaceId: string;
|
|
parentPageId?: string;
|
|
temporary?: boolean;
|
|
} = { spaceId };
|
|
if (parentId) payload.parentPageId = parentId;
|
|
// Ask the server to arm the death timer for a "temporary note".
|
|
if (opts?.temporary) payload.temporary = true;
|
|
|
|
let createdPage: IPage;
|
|
try {
|
|
createdPage = await createPageMutation.mutateAsync(payload);
|
|
} catch {
|
|
throw new Error("Failed to create page");
|
|
}
|
|
|
|
// Route through the canonical mapper so the field copy (esp.
|
|
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
|
|
// optimistic insert) can't drift from buildTree. `name: ""` because a
|
|
// freshly created page is untitled; `hasChildren: false` because it has no
|
|
// children yet.
|
|
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
|
|
name: "",
|
|
hasChildren: false,
|
|
});
|
|
|
|
// Read latest tree at call time. Without this, callers that mutate the
|
|
// tree (e.g. lazy-load children on expand) immediately before calling
|
|
// handleCreate hit a stale closure and compute lastIndex against the
|
|
// pre-load tree, requiring a setTimeout-based wait at the call site.
|
|
const current = store.get(treeDataAtom);
|
|
let lastIndex: number;
|
|
if (parentId === null) {
|
|
lastIndex = current.length;
|
|
} else {
|
|
const parent = treeModel.find(current, parentId);
|
|
lastIndex = parent?.children?.length ?? 0;
|
|
}
|
|
|
|
// Idempotent by id: the tree is server-authoritative and the server's
|
|
// `addTreeNode` broadcast (now ~ms over same-origin) can win the race and
|
|
// insert this node before this optimistic update runs. Inserting again
|
|
// un-guarded would duplicate the row in the author's sidebar. Mirror the
|
|
// `addTreeNode` socket guard: skip when the node already exists. The
|
|
// optimistic node's id IS the real created page id (createdPage.id), so
|
|
// the ids match exactly regardless of which path runs first.
|
|
setData((prev) => {
|
|
const existing = treeModel.find(prev, newNode.id);
|
|
if (existing) {
|
|
// The server `addTreeNode` broadcast won the race and already inserted
|
|
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
|
|
// a temporary note WITHOUT its clock marker until reload; patch it on
|
|
// from the authoritative create response so the marker shows now.
|
|
if (
|
|
newNode.temporaryExpiresAt &&
|
|
!(existing as SpaceTreeNode).temporaryExpiresAt
|
|
) {
|
|
return treeModel.update(prev, newNode.id, {
|
|
temporaryExpiresAt: newNode.temporaryExpiresAt,
|
|
} as Partial<SpaceTreeNode>);
|
|
}
|
|
return prev;
|
|
}
|
|
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
|
});
|
|
|
|
// Realtime broadcast is now server-authoritative: the server emits
|
|
// `addTreeNode` to the space room on PAGE_CREATED. The old client relay
|
|
// (emit + setTimeout(50)) was removed; the optimistic insert above stays
|
|
// for instant feedback to the author (the server event is idempotent and
|
|
// a no-op for the author whose node already exists).
|
|
const pageUrl = buildPageUrl(
|
|
spaceSlug,
|
|
createdPage.slugId,
|
|
createdPage.title,
|
|
);
|
|
navigate(pageUrl);
|
|
},
|
|
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
async (id: string) => {
|
|
const node = treeModel.find(
|
|
store.get(treeDataAtom),
|
|
id,
|
|
) as SpaceTreeNode | null;
|
|
const parentPageId = node?.parentPageId ?? null;
|
|
try {
|
|
await removePageMutation.mutateAsync(id);
|
|
setData((prev) => {
|
|
let next = treeModel.remove(prev, id);
|
|
// If the parent has no children left, mark hasChildren: false so the
|
|
// chevron disappears. Without this, the empty parent keeps rendering an
|
|
// expand toggle that fetches zero rows on click.
|
|
if (parentPageId) {
|
|
const parent = treeModel.find(next, parentPageId);
|
|
if (!parent?.children?.length) {
|
|
next = treeModel.update(next, parentPageId, {
|
|
hasChildren: false,
|
|
} as Partial<SpaceTreeNode>);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
|
|
if (
|
|
node &&
|
|
pageSlug &&
|
|
(node.slugId === pageSlug.split("-")[1] ||
|
|
isPageInNode(node, pageSlug.split("-")[1]))
|
|
) {
|
|
navigate(getSpaceUrl(spaceSlug));
|
|
}
|
|
|
|
// Realtime broadcast is now server-authoritative: the server emits
|
|
// `deleteTreeNode` to the space room on PAGE_SOFT_DELETED. The old
|
|
// client relay (emit + setTimeout(50)) was removed; the optimistic
|
|
// removal above stays for instant feedback to the author.
|
|
} catch (error) {
|
|
console.error("Failed to delete page:", error);
|
|
}
|
|
},
|
|
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
|
);
|
|
|
|
return { handleMove, handleCreate, handleDelete };
|
|
}
|
|
|
|
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
|
if (node.slugId === pageSlug) return true;
|
|
if (!node.children) return false;
|
|
for (const child of node.children) {
|
|
if (isPageInNode(child, pageSlug)) return true;
|
|
}
|
|
return false;
|
|
}
|