Merge remote-tracking branch 'gitea/develop' into feat/share-chat-reuse-internal

This commit is contained in:
claude_code
2026-06-21 01:28:14 +03:00
100 changed files with 10437 additions and 623 deletions

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { describeChatError } from "./error-message";
// Identity translator: assert on the raw English key so the tests do not depend
// on the i18n catalog.
const t = (key: string) => key;
describe("describeChatError", () => {
it('surfaces a provider "402: ..." stream error verbatim', () => {
expect(describeChatError("402: Insufficient credits", t)).toBe(
"402: Insufficient credits",
);
});
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
// A provider message mentioning the number 403 must be surfaced verbatim,
// never folded into the "AI chat is disabled" gating message.
const msg = "429: rate limited after 403 attempts";
expect(describeChatError(msg, t)).toBe(msg);
});
it('maps a {"statusCode":403} body to the disabled message', () => {
const body = '{"statusCode":403,"message":"Forbidden"}';
expect(describeChatError(body, t)).toBe(
"AI chat is disabled for this workspace.",
);
});
it('maps a {"statusCode":503} body to the not-configured message', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}';
expect(describeChatError(body, t)).toBe(
"The AI provider is not configured. Ask an administrator to set it up.",
);
});
it('falls back to the generic message for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
it('falls back to the generic message for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
it("falls back to the generic message for empty input", () => {
expect(describeChatError("", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
toolCitations,
toolRunState,
type ToolUiPart,
} from "./tool-parts";
describe("toolCitations", () => {
it("emits one citation per searchPages item with a /p/{id} href", () => {
const part: ToolUiPart = {
type: "tool-searchPages",
state: "output-available",
output: [
{ id: "p1", title: "First" },
{ id: "p2", title: "Second" },
],
};
expect(toolCitations(part)).toEqual([
{ pageId: "p1", title: "First", href: "/p/p1" },
{ pageId: "p2", title: "Second", href: "/p/p2" },
]);
});
it("drops searchPages items missing an id", () => {
const part: ToolUiPart = {
type: "tool-searchPages",
state: "output-available",
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
};
expect(toolCitations(part)).toEqual([
{ pageId: "p2", title: "Kept", href: "/p/p2" },
]);
});
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
const part: ToolUiPart = {
type: "tool-updatePageContent",
state: "output-available",
input: { pageId: "host-1", title: "From input" },
output: { pageId: "host-1" },
};
expect(toolCitations(part)).toEqual([
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
]);
});
it("prefers output.id over input.pageId when both exist", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "output-available",
input: { pageId: "input-id", title: "Input title" },
output: { id: "output-id", title: "Output title" },
};
expect(toolCitations(part)).toEqual([
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
]);
});
it("returns [] when the state is not output-available", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "input-available",
output: { id: "p1", title: "Pending" },
};
expect(toolCitations(part)).toEqual([]);
});
it("returns [] for a page-op output with no resolvable id", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "output-available",
input: {},
output: { title: "Only a title" },
};
expect(toolCitations(part)).toEqual([]);
});
});
describe("toolRunState", () => {
it('maps "output-error" to error', () => {
expect(toolRunState("output-error")).toBe("error");
});
it('maps "output-denied" to error', () => {
expect(toolRunState("output-denied")).toBe("error");
});
it('maps "output-available" to done', () => {
expect(toolRunState("output-available")).toBe("done");
});
it('maps "input-available" to running', () => {
expect(toolRunState("input-available")).toBe("running");
});
it("maps undefined to running", () => {
expect(toolRunState(undefined)).toBe("running");
});
});

View File

@@ -0,0 +1,48 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { getFootnoteNumber } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
/**
* NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored).
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
// Read the cached number from the numbering plugin (computed once per doc
// change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
editor.commands.scrollToReference(id);
};
return (
<NodeViewWrapper
data-footnote-def=""
data-id={id}
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
<span className={classes.definitionMarker} contentEditable={false}>
{number}.
</span>
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.backLink}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import {
FOOTNOTE_DEFINITION_NAME,
getFootnoteNumber,
} from "@docmost/editor-ext";
import { ActionIcon } from "@mantine/core";
import { IconArrowDown } from "@tabler/icons-react";
import classes from "./footnote.module.css";
/**
* Read the plain text of the footnote definition with `id` directly from the
* editor state. No sub-editor: the popover is read-only.
*/
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
let text = "";
editor.state.doc.descendants((node) => {
if (
node.type.name === FOOTNOTE_DEFINITION_NAME &&
node.attrs.id === id
) {
text = node.textContent;
return false;
}
return undefined;
});
return text;
}
export default function FootnoteReferenceView(props: NodeViewProps) {
const { node, editor, selected } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
const anchorRef = useRef<HTMLElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
// Number is derived (not stored). Read it from the numbering plugin's cached
// map (computed once per doc change) instead of walking the whole document on
// every render — recomputing per NodeView per render was O(n^2) per keystroke.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const defText = open ? getDefinitionText(editor, id) : "";
const position = useCallback(() => {
const anchor = anchorRef.current;
const popup = popoverRef.current;
if (!anchor || !popup) return;
computePosition(anchor, popup, {
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
});
}, []);
useEffect(() => {
if (!open) return;
const anchor = anchorRef.current;
const popup = popoverRef.current;
if (!anchor || !popup) return;
const cleanup = autoUpdate(anchor, popup, position);
const onPointerDown = (e: PointerEvent) => {
if (
popup.contains(e.target as Node) ||
anchor.contains(e.target as Node)
) {
return;
}
setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown, true);
return () => {
cleanup();
document.removeEventListener("pointerdown", onPointerDown, true);
};
}, [open, position]);
const handleGoTo = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
editor.commands.scrollToFootnote(id);
};
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
<sup
ref={(el) => (anchorRef.current = el)}
data-footnote-ref=""
data-id={id}
className={`${classes.reference} ${selected ? classes.selected : ""}`}
onMouseEnter={() => setOpen(true)}
onClick={(e) => {
e.preventDefault();
setOpen((v) => !v);
}}
// The decoration sets --footnote-number; provide a fallback inline.
style={{ ["--footnote-number" as any]: `"${number}"` }}
aria-label={t("Footnote {{number}}", { number })}
role="button"
/>
{open &&
createPortal(
<div
ref={popoverRef}
className={classes.popover}
role="tooltip"
onMouseLeave={() => setOpen(false)}
>
<div className={classes.popoverHeader}>
<span className={classes.popoverNumber}>
{t("Footnote {{number}}", { number })}
</span>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={handleGoTo}
aria-label={t("Go to footnote")}
>
<IconArrowDown size={16} />
</ActionIcon>
</div>
<div className={classes.popoverBody}>
{defText || t("Empty footnote")}
</div>
</div>,
document.body,
)}
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,111 @@
/* Superscript reference marker. The visible number comes from the numbering
plugin decoration which sets the --footnote-number CSS variable. */
.reference {
cursor: pointer;
color: var(--mantine-color-blue-6);
font-weight: 500;
vertical-align: super;
font-size: 0.75em;
line-height: 0;
user-select: none;
white-space: nowrap;
}
.reference::after {
content: var(--footnote-number, "");
}
.reference:hover {
text-decoration: underline;
}
.reference.selected {
background-color: var(--mantine-color-blue-1);
border-radius: 2px;
}
/* Read-only popover shown on hover/click of a reference. */
.popover {
position: absolute;
z-index: 1000;
max-width: 360px;
padding: var(--mantine-spacing-sm);
background: var(--mantine-color-body);
color: var(--mantine-color-default-color);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-md);
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
}
.popoverHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
margin-bottom: 4px;
}
.popoverNumber {
font-weight: 600;
color: var(--mantine-color-dimmed);
}
.popoverBody {
white-space: pre-wrap;
word-break: break-word;
}
/* Bottom footnotes container. */
.list {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
}
.listHeading {
font-weight: 600;
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
margin-bottom: var(--mantine-spacing-xs);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.definition {
display: flex;
align-items: flex-start;
/* Tight number→text spacing (~one space) so it reads like "1. text"
instead of leaving a wide gap after the period. */
gap: 0.4em;
padding: 2px 0;
}
.definitionMarker {
flex: 0 0 auto;
min-width: 1.5em;
/* Right-align within the narrow column so the period sits next to the text
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--mantine-color-dimmed);
user-select: none;
}
.definitionContent {
flex: 1 1 auto;
min-width: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;
color: var(--mantine-color-blue-6);
user-select: none;
font-size: 0.9em;
}
.backLink:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,20 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import classes from "./footnote.module.css";
/**
* NodeView for the bottom footnotes container. Renders a visual separator and a
* localized heading, then the editable list of definitions via NodeViewContent.
*/
export default function FootnotesListView(_props: NodeViewProps) {
const { t } = useTranslation();
return (
<NodeViewWrapper>
<div className={classes.list} contentEditable={false}>
<div className={classes.listHeading}>{t("Footnotes")}</div>
</div>
<NodeViewContent />
</NodeViewWrapper>
);
}

View File

@@ -15,39 +15,11 @@ import { useAtomValue } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./html-embed-view.module.css";
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
*
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
* parses that way: the HTML spec marks scripts inserted via innerHTML as
* "already started" so they never execute. To get the tracker/analytics
* use-case working we walk the freshly-parsed scripts and replace each with a
* brand-new <script> element copying its attributes and inline code. A
* programmatically created+inserted <script> DOES execute, so this restores
* normal script behaviour in the wiki origin (Variant C).
*/
function renderRawHtml(container: HTMLElement, source: string) {
// Clear any previous render (re-render on source change).
container.innerHTML = "";
if (!source) return;
container.innerHTML = source;
const scripts = Array.from(container.querySelectorAll("script"));
for (const oldScript of scripts) {
const newScript = document.createElement("script");
// Copy every attribute (src, type, async, defer, data-*, etc.).
for (const attr of Array.from(oldScript.attributes)) {
newScript.setAttribute(attr.name, attr.value);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
}
import {
canEdit as computeCanEdit,
renderRawHtml,
shouldExecute as computeShouldExecute,
} from "./render-raw-html.ts";
export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation();
@@ -70,7 +42,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
// here — we execute exactly the `source` the server chose to serve.
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
// toggle so an admin sees the inert placeholder when the feature is OFF.
const shouldExecute = !editor.isEditable || htmlEmbedEnabled;
const shouldExecute = computeShouldExecute(
editor.isEditable,
htmlEmbedEnabled,
);
const contentRef = useRef<HTMLDivElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
@@ -104,7 +79,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
// The edit affordance is only meaningful in edit mode, is restricted to admins
// (the server strips the node for non-admins anyway), and is offered only when
// the workspace feature toggle is ON.
const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled;
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
return (
<NodeViewWrapper

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { JSDOM } from "jsdom";
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
// jsdom does NOT execute <script> nodes unless its instance was created with
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
// re-created scripts run, so the execution tests drive a dedicated script-
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
// `container.ownerDocument`, so it creates the fresh scripts in the running
// instance). The default vitest jsdom (no runScripts) is used for the
// structural and policy assertions.
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
let dom: JSDOM;
let container: HTMLElement;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>", {
runScripts: "dangerously",
});
container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
});
afterEach(() => {
dom.window.close();
});
it("re-creates and executes an inline <script> (observable side effect)", () => {
renderRawHtml(
container,
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
);
// The re-created inline script ran inside the jsdom window.
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
true,
);
// The non-script markup is preserved.
expect(container.querySelector("div")?.textContent).toBe("hello");
});
it("copies src/async/defer onto a re-created external <script src>", () => {
renderRawHtml(
container,
'<script src="https://example.com/t.js" async defer></script>',
);
const script = container.querySelector("script");
expect(script).not.toBeNull();
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
expect(script?.hasAttribute("async")).toBe(true);
expect(script?.hasAttribute("defer")).toBe(true);
});
it("clears the container when the source is empty", () => {
container.innerHTML = "<p>stale</p>";
renderRawHtml(container, "");
expect(container.innerHTML).toBe("");
});
it("clears prior content first on a re-render with new source", () => {
const win = dom.window as unknown as Record<string, unknown>;
renderRawHtml(
container,
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
);
expect(win.__htmlEmbedCount).toBe(1);
expect(container.querySelector("#first")).not.toBeNull();
renderRawHtml(
container,
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
);
// Prior content is gone; only the new render remains.
expect(container.querySelector("#first")).toBeNull();
expect(container.querySelector("#second")).not.toBeNull();
expect(win.__htmlEmbedCount).toBe(2);
});
});
describe("shouldExecute (execution policy)", () => {
it("read-only executes regardless of the workspace toggle", () => {
// isEditable=false → the server already gated the content.
expect(shouldExecute(false, false)).toBe(true);
expect(shouldExecute(false, true)).toBe(true);
});
it("editable + toggle OFF does NOT execute", () => {
expect(shouldExecute(true, false)).toBe(false);
});
it("editable + toggle ON executes", () => {
expect(shouldExecute(true, true)).toBe(true);
});
});
describe("canEdit (edit policy)", () => {
it("a member (non-admin) can never edit", () => {
expect(canEdit(true, false, true)).toBe(false);
expect(canEdit(false, false, true)).toBe(false);
});
it("an admin with the toggle OFF cannot edit", () => {
expect(canEdit(true, true, false)).toBe(false);
});
it("an admin with the toggle ON in editable mode can edit", () => {
expect(canEdit(true, true, true)).toBe(true);
});
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
expect(canEdit(false, true, true)).toBe(false);
});
});

View File

@@ -0,0 +1,73 @@
/**
* Pure DOM helpers for the HTML embed node view. Kept out of the React
* component so the script re-creation/execution mechanism and the execution/
* edit policy can be unit-tested against a bare jsdom container with no
* Tiptap/Mantine providers.
*/
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
*
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
* parses that way: the HTML spec marks scripts inserted via innerHTML as
* "already started" so they never execute. To get the tracker/analytics
* use-case working we walk the freshly-parsed scripts and replace each with a
* brand-new <script> element copying its attributes and inline code. A
* programmatically created+inserted <script> DOES execute, so this restores
* normal script behaviour in the wiki origin (Variant C).
*/
export function renderRawHtml(container: HTMLElement, source: string): void {
// Clear any previous render (re-render on source change).
container.innerHTML = "";
if (!source) return;
container.innerHTML = source;
// Use the container's own document so the helper works against any document
// (the live page or a standalone jsdom instance in tests), not just the
// ambient global `document`.
const doc = container.ownerDocument;
const scripts = Array.from(container.querySelectorAll("script"));
for (const oldScript of scripts) {
const newScript = doc.createElement("script");
// Copy every attribute (src, type, async, defer, data-*, etc.).
for (const attr of Array.from(oldScript.attributes)) {
newScript.setAttribute(attr.name, attr.value);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
}
/**
* Execution policy split by editor mode:
* - READ-ONLY / public-share view: the SERVER already decided whether to
* include the embed (it strips htmlEmbed from shared content when the
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
* reads `featureEnabled` as false, so we must NOT gate execution on it here
* — we execute exactly the `source` the server chose to serve.
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
* so an admin sees the inert placeholder when the feature is OFF.
*/
export function shouldExecute(
isEditable: boolean,
featureEnabled: boolean,
): boolean {
return !isEditable || featureEnabled;
}
/**
* The edit affordance is only meaningful in edit mode, is restricted to admins
* (the server strips the node for non-admins anyway), and is offered only when
* the workspace feature toggle is ON.
*/
export function canEdit(
isEditable: boolean,
isAdmin: boolean,
featureEnabled: boolean,
): boolean {
return isEditable && isAdmin && featureEnabled;
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { decideEmbedState } from "./decide-embed-state";
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
const okResult: PageTemplateLookup = {
sourcePageId: "p1",
slugId: "slug-p1",
title: "Template",
icon: null,
content: { type: "doc" },
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
};
describe("decideEmbedState", () => {
it("returns no_source when sourcePageId is null", () => {
expect(
decideEmbedState({
sourcePageId: null,
chain: [],
hostPageId: null,
available: true,
result: null,
}),
).toBe("no_source");
});
it("returns cycle when sourcePageId is already in the ancestor chain", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: ["root", "p1"],
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("cycle");
});
it("returns cycle when sourcePageId equals the host page id (top-level self-embed)", () => {
expect(
decideEmbedState({
sourcePageId: "host",
chain: [],
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("cycle");
});
it("returns too_deep when chain length reaches PAGE_EMBED_MAX_DEPTH", () => {
const chain = Array.from({ length: PAGE_EMBED_MAX_DEPTH }, (_, i) => `a${i}`);
expect(
decideEmbedState({
sourcePageId: "p1",
chain,
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("too_deep");
});
it("cycle wins over too_deep when both apply (cycle checked first)", () => {
const chain = Array.from(
{ length: PAGE_EMBED_MAX_DEPTH },
(_, i) => `a${i}`,
);
chain[0] = "p1"; // also a cycle
expect(
decideEmbedState({
sourcePageId: "p1",
chain,
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("cycle");
});
it("returns unavailable when no lookup context is mounted", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: false,
result: null,
}),
).toBe("unavailable");
});
it("returns loading when available but the result is not back yet", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: null,
}),
).toBe("loading");
});
it("returns no_access when the result status is no_access", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: { sourcePageId: "p1", status: "no_access" },
}),
).toBe("no_access");
});
it("returns not_found when the result status is not_found", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: { sourcePageId: "p1", status: "not_found" },
}),
).toBe("not_found");
});
it("returns ok for a resolved template (happy path)", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("ok");
});
});

View File

@@ -0,0 +1,58 @@
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
/**
* The render outcome of a single pageEmbed node, decided BEFORE rendering a
* nested editor. Kept pure (no React) so the cycle / depth / access / not-found
* branch logic is unit-testable in isolation; the node view maps each outcome
* to a placeholder or the embedded content.
*/
export type EmbedState =
| "no_source" // no sourcePageId picked yet
| "cycle" // self-embed or an ancestor already shows this page
| "too_deep" // nesting depth limit reached
| "unavailable" // no lookup context (e.g. public share)
| "loading" // context present, result not back yet
| "ok" // resolved template content to render
| "no_access" // server says the viewer can't see the page
| "not_found"; // server says the page no longer exists
export interface DecideEmbedStateInput {
sourcePageId: string | null;
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
chain: string[];
/** Host page id; a top-level self-embed must be caught against it. */
hostPageId: string | null;
/** Whether a lookup context is mounted (false on public shares in MVP). */
available: boolean;
/** The lookup result, or null while still loading. */
result: PageTemplateLookup | null;
}
/**
* Decide what a pageEmbed should render. The order matters: cycle and depth
* guards run first (before any lookup is even consulted), then availability,
* then the resolved result. Mirrors the branch ladder in PageEmbedBody.
*/
export function decideEmbedState({
sourcePageId,
chain,
hostPageId,
available,
result,
}: DecideEmbedStateInput): EmbedState {
if (!sourcePageId) return "no_source";
// Self-embed or a source already present in the ancestor chain → cycle.
const isCycle = chain.includes(sourcePageId) || hostPageId === sourcePageId;
if (isCycle) return "cycle";
if (chain.length >= PAGE_EMBED_MAX_DEPTH) return "too_deep";
if (!available) return "unavailable";
if (!result) return "loading";
if (!("status" in result)) return "ok";
if (result.status === "no_access") return "no_access";
return "not_found";
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
PageEmbedAncestryProvider,
usePageEmbedAncestry,
} from "./page-embed-ancestry-context";
// Probe child: renders the current ancestry context value as JSON so the test
// can assert on the accumulated chain and host without any Tiptap editor.
function Probe({ testId }: { testId: string }) {
const ancestry = usePageEmbedAncestry();
return <div data-testid={testId}>{JSON.stringify(ancestry)}</div>;
}
function read(el: HTMLElement) {
return JSON.parse(el.textContent || "{}") as {
chain: string[];
hostPageId: string | null;
};
}
describe("PageEmbedAncestryProvider", () => {
it("accumulates the chain in order across nested providers", () => {
const { getByTestId } = render(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
<PageEmbedAncestryProvider sourcePageId="b">
<PageEmbedAncestryProvider sourcePageId="c">
<Probe testId="leaf" />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const value = read(getByTestId("leaf"));
expect(value.chain).toEqual(["a", "b", "c"]);
expect(value.hostPageId).toBe("host");
});
it("leaves the chain unchanged when sourcePageId is absent, still propagating the host", () => {
const { getByTestId } = render(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
<PageEmbedAncestryProvider>
<Probe testId="leaf" />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const value = read(getByTestId("leaf"));
expect(value.chain).toEqual(["a"]);
expect(value.hostPageId).toBe("host");
});
it("keeps the first (top-level) host even if an inner provider passes a different one", () => {
const { getByTestId } = render(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="top-host">
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="inner-host">
<Probe testId="leaf" />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const value = read(getByTestId("leaf"));
expect(value.chain).toEqual(["a", "b"]);
// Inner host is ignored: the top-level host is set once and propagated.
expect(value.hostPageId).toBe("top-host");
});
it("defaults to an empty chain and null host with no provider", () => {
const { getByTestId } = render(<Probe testId="leaf" />);
const value = read(getByTestId("leaf"));
expect(value.chain).toEqual([]);
expect(value.hostPageId).toBeNull();
});
});

View File

@@ -0,0 +1,162 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from "vitest";
import { act, render } from "@testing-library/react";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
// Mock the API module the provider calls. Hoisted by vitest before the import.
const lookupTemplate = vi.fn();
vi.mock("@/features/page-embed/services/page-embed-api", () => ({
lookupTemplate: (...args: unknown[]) => lookupTemplate(...args),
}));
// Imported AFTER the mock is declared so the provider picks up the mock.
import {
PageEmbedLookupProvider,
usePageEmbedLookup,
} from "./page-embed-lookup-context";
function ok(id: string): PageTemplateLookup {
return {
sourcePageId: id,
slugId: `slug-${id}`,
title: `T-${id}`,
icon: null,
content: { type: "doc" },
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
};
}
// Probe that subscribes to a sourceId and exposes its latest result + refresh.
function Probe({
id,
sink,
}: {
id: string;
sink: (api: ReturnType<typeof usePageEmbedLookup>) => void;
}) {
const api = usePageEmbedLookup(id);
sink(api);
return <div>{api.result ? "loaded" : "pending"}</div>;
}
describe("PageEmbedLookupProvider (batching / dedup / refresh)", () => {
beforeEach(() => {
vi.useFakeTimers();
lookupTemplate.mockReset();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("dedups two subscribers for the same id into a single lookup call; both get the result", async () => {
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
let b: ReturnType<typeof usePageEmbedLookup> | null = null;
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={(x) => (a = x)} />
<Probe id="p1" sink={(x) => (b = x)} />
</PageEmbedLookupProvider>,
);
// Subscriptions run in effects + the 10ms debounce batches them together.
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(lookupTemplate).toHaveBeenCalledTimes(1);
expect(lookupTemplate).toHaveBeenCalledWith({ sourcePageIds: ["p1"] });
expect(a!.result).toEqual(ok("p1"));
expect(b!.result).toEqual(ok("p1"));
});
it("batches two distinct ids subscribed within the window into one call", async () => {
lookupTemplate.mockResolvedValue({ items: [ok("p1"), ok("p2")] });
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={() => {}} />
<Probe id="p2" sink={() => {}} />
</PageEmbedLookupProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(lookupTemplate).toHaveBeenCalledTimes(1);
expect(lookupTemplate.mock.calls[0][0]).toEqual({
sourcePageIds: ["p1", "p2"],
});
});
it("refresh() clears the cache and re-fetches", async () => {
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={(x) => (a = x)} />
</PageEmbedLookupProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(lookupTemplate).toHaveBeenCalledTimes(1);
// refresh resolves once the next batch flush completes.
await act(async () => {
const p = a!.refresh();
await vi.advanceTimersByTimeAsync(20);
await p;
});
expect(lookupTemplate).toHaveBeenCalledTimes(2);
});
it("a rejected lookup resolves refresh() waiters, clears inFlight, and logs the error (not swallowed)", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
lookupTemplate.mockRejectedValueOnce(new Error("boom"));
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={(x) => (a = x)} />
</PageEmbedLookupProvider>,
);
// Initial subscription enqueues a lookup that rejects.
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(errSpy).toHaveBeenCalled();
// The error message is surfaced, not swallowed.
expect(errSpy.mock.calls[0][0]).toContain("[pageEmbed] template lookup failed");
// inFlight was cleared on failure, so a refresh re-enqueues and resolves.
lookupTemplate.mockResolvedValueOnce({ items: [ok("p1")] });
let resolved = false;
await act(async () => {
const p = a!.refresh().then(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(20);
await p;
});
expect(resolved).toBe(true);
expect(a!.result).toEqual(ok("p1"));
errSpy.mockRestore();
});
});

View File

@@ -6,6 +6,7 @@ import { IconFileText, IconSearch } from "@tabler/icons-react";
import type { Editor, Range } from "@tiptap/core";
import { searchSuggestions } from "@/features/search/services/search-service";
import type { IPage } from "@/features/page/types/page.types";
import { buildPickerQuery, excludeHost } from "./page-embed-picker.utils";
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
@@ -43,21 +44,13 @@ export default function PageEmbedPicker() {
const { data, isFetching } = useQuery({
queryKey: ["page-embed-template-picker", query],
queryFn: () =>
searchSuggestions({
query,
includePages: true,
onlyTemplates: true,
limit: 20,
}),
queryFn: () => searchSuggestions(buildPickerQuery(query)),
enabled: opened,
staleTime: 30 * 1000,
});
const hostPageId = detailRef.current?.hostPageId;
const pages = ((data?.pages ?? []) as IPage[]).filter(
(p) => p && p.id !== hostPageId,
);
const pages = excludeHost((data?.pages ?? []) as IPage[], hostPageId);
const handleSelect = (page: IPage) => {
const detail = detailRef.current;

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { excludeHost, buildPickerQuery } from "./page-embed-picker.utils";
import type { IPage } from "@/features/page/types/page.types";
function page(id: string): IPage {
return { id, title: id, slugId: `slug-${id}` } as IPage;
}
describe("excludeHost", () => {
it("drops the host page from the results (self-embed guard)", () => {
const result = excludeHost([page("a"), page("host"), page("b")], "host");
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
});
it("returns all pages when hostPageId is undefined", () => {
const result = excludeHost([page("a"), page("b")], undefined);
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
});
it("drops null/blank entries", () => {
const result = excludeHost(
[page("a"), null as unknown as IPage, page("b")],
"host",
);
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
});
});
describe("buildPickerQuery", () => {
it("passes onlyTemplates:true with the query and page inclusion", () => {
expect(buildPickerQuery("foo")).toEqual({
query: "foo",
includePages: true,
onlyTemplates: true,
limit: 20,
});
});
it("preserves an empty query", () => {
expect(buildPickerQuery("").query).toBe("");
expect(buildPickerQuery("").onlyTemplates).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import type { IPage } from "@/features/page/types/page.types";
import type { SearchSuggestionParams } from "@/features/search/types/search.types";
/**
* Self-embed guard at insertion time: drop the host page (and any null/blank
* entries) from the picker results so the current page can't embed itself.
*/
export function excludeHost(
pages: IPage[],
hostPageId: string | undefined,
): IPage[] {
return pages.filter((p) => p && p.id !== hostPageId);
}
/**
* Build the search-suggestions query for the template picker. Always restricts
* to template-flagged pages (`onlyTemplates`) and includes pages, mirroring the
* inline query args in PageEmbedPicker.
*/
export function buildPickerQuery(query: string): SearchSuggestionParams {
return {
query,
includePages: true,
onlyTemplates: true,
limit: 20,
};
}

View File

@@ -21,8 +21,8 @@ import { usePageEmbedLookup } from "./page-embed-lookup-context";
import {
PageEmbedAncestryProvider,
usePageEmbedAncestry,
PAGE_EMBED_MAX_DEPTH,
} from "./page-embed-ancestry-context";
import { decideEmbedState } from "./decide-embed-state";
import PageEmbedContent from "./page-embed-content";
function Placeholder({
@@ -99,13 +99,15 @@ function PageEmbedBody({
}
};
// --- Cycle / depth guard (evaluated before any lookup is rendered) ---------
// Self-embed or a source already present in the ancestor chain → cycle.
const isCycle =
!!sourcePageId &&
(ancestry.chain.includes(sourcePageId) ||
ancestry.hostPageId === sourcePageId);
const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH;
// --- Cycle / depth / availability decision (pure, unit-tested) ------------
// Evaluated before any nested editor is rendered.
const embedState = decideEmbedState({
sourcePageId,
chain: ancestry.chain,
hostPageId: ancestry.hostPageId,
available,
result,
});
const sourceTitle =
result && !("status" in result) ? result.title : null;
@@ -187,28 +189,28 @@ function PageEmbedBody({
) : null;
let body: React.ReactNode;
if (!sourcePageId) {
if (embedState === "no_source") {
body = (
<Placeholder
icon={<IconInfoCircle size={18} stroke={1.6} />}
label={t("No page selected")}
/>
);
} else if (isCycle) {
} else if (embedState === "cycle") {
body = (
<Placeholder
icon={<IconRepeat size={18} stroke={1.6} />}
label={t("Circular embed: this page is already shown above")}
/>
);
} else if (isTooDeep) {
} else if (embedState === "too_deep") {
body = (
<Placeholder
icon={<IconRepeat size={18} stroke={1.6} />}
label={t("Embed nesting limit reached")}
/>
);
} else if (!available) {
} else if (embedState === "unavailable") {
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
body = (
<Placeholder
@@ -216,9 +218,9 @@ function PageEmbedBody({
label={t("Embedded page is not available here")}
/>
);
} else if (!result) {
} else if (embedState === "loading") {
body = <div style={{ minHeight: 24 }} />;
} else if (!("status" in result)) {
} else if (embedState === "ok" && result && !("status" in result)) {
body = (
<PageEmbedAncestryProvider
sourcePageId={sourcePageId}
@@ -227,7 +229,7 @@ function PageEmbedBody({
<PageEmbedContent content={result.content} />
</PageEmbedAncestryProvider>
);
} else if (result.status === "no_access") {
} else if (embedState === "no_access") {
body = (
<Placeholder
icon={<IconEyeOff size={18} stroke={1.6} />}

View File

@@ -28,6 +28,7 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
IconArrowsMaximize,
} from "@tabler/icons-react";
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
@@ -368,6 +369,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDetails().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setFootnote().run(),
},
{
title: "Callout",
description: "Insert callout notice.",

View File

@@ -63,6 +63,9 @@ import {
TransclusionReference,
PageEmbed,
TableView,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,6 +97,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
@@ -392,6 +398,19 @@ export const mainExtensions = [
TransclusionReference.configure({
view: TransclusionReferenceView,
}),
FootnoteReference.configure({
view: FootnoteReferenceView,
// Skip orphan-cleanup on remote/collaboration steps so collaborating
// clients never fight over footnote integrity (deterministic numbering
// decorations handle the rest).
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
}),
FootnotesList.configure({
view: FootnotesListView,
}),
FootnoteDefinition.configure({
view: FootnoteDefinitionView,
}),
PageEmbed.configure({
view: PageEmbedView,
}),

View File

@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
}, []);
const extensions = useMemo(() => {
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
const filteredExtensions = mainExtensions
.filter((ext) => ext.name !== "uniqueID")
// Read-only must only DECORATE footnotes (numbering), never mutate the
// doc. Disable the footnote sync/integrity plugin so a programmatic
// setContent on a doc the viewer can't edit is never rewritten.
.map((ext) =>
ext.name === "footnoteReference"
? ext.configure({ enableSync: false })
: ext,
);
return [
...filteredExtensions,

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createRef } from "react";
import { render, waitFor, cleanup } from "@testing-library/react";
// --- Mocks for the heavy / networked module graph ---------------------------
// SpaceTree pulls in query hooks, page services, i18n, notifications and two
// child render components. The expandAll contract is exercised purely through
// the imperative ref, so we mock everything that would otherwise need a real
// server / router and stub the visual children to empty renders.
const getSpaceTreeMock = vi.fn();
const notificationsShowMock = vi.fn();
vi.mock("@/features/page/services/page-service.ts", () => ({
getSpaceTree: (...args: unknown[]) => getSpaceTreeMock(...args),
getPageBreadcrumbs: vi.fn(),
}));
vi.mock("@/features/page/queries/page-query.ts", () => ({
// No root pages and no further pages — the data-load effect is inert so the
// test fully controls the tree through expandAll.
useGetRootSidebarPagesQuery: () => ({
data: undefined,
hasNextPage: false,
fetchNextPage: vi.fn(),
isFetching: false,
}),
usePageQuery: () => ({ data: undefined }),
fetchAllAncestorChildren: vi.fn(),
}));
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
useTreeMutation: () => ({ handleMove: vi.fn() }),
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: (...args: unknown[]) => notificationsShowMock(...args) },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("react-router-dom", () => ({
useParams: () => ({ pageSlug: undefined }),
}));
vi.mock("@/lib", () => ({
extractPageSlugId: () => undefined,
}));
vi.mock("@/lib/config.ts", () => ({
isCompactPageTreeEnabled: () => false,
}));
// Stub the visual children so we don't drag in the full DnD / Mantine stack.
vi.mock("./doc-tree", () => ({
DocTree: () => null,
ROW_HEIGHT_COMPACT: 28,
ROW_HEIGHT_STANDARD: 32,
}));
vi.mock("./space-tree-row", () => ({
SpaceTreeRow: () => null,
}));
vi.mock("@mantine/core", () => ({
Text: ({ children }: { children?: unknown }) => children ?? null,
}));
// The real openTreeNodesAtom is localStorage-backed (atomWithStorage +
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
// plain in-memory atom with the same read value (OpenMap) and the same setter
// shape (value OR functional updater) so the component's open-state logic runs
// unchanged while staying inside the test store.
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
const { atom } = await import("jotai");
type OpenMap = Record<string, boolean>;
const base = atom<OpenMap>({});
const openTreeNodesAtom = atom(
(get) => get(base),
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
const next =
typeof update === "function"
? (update as (prev: OpenMap) => OpenMap)(get(base))
: update;
set(base, next);
},
);
return { openTreeNodesAtom };
});
import SpaceTree, { SpaceTreeApi } from "./space-tree";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
import { createStore, Provider } from "jotai";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
// A flat space-tree response (parentPageId pointers) that buildTree +
// buildTreeWithChildren nest into a multi-level tree. Depth > 1 lets us assert
// expandAll never fans out into per-branch fetches (no N+1).
function spaceTreeItems(): SpaceTreeNode[] {
const n = (
id: string,
parentPageId: string | null,
position: string,
): SpaceTreeNode => ({
id,
slugId: `slug-${id}`,
name: id,
icon: undefined,
position,
spaceId: "space-1",
parentPageId: parentPageId as unknown as string,
hasChildren: false,
children: [],
});
return [
n("root", null, "a0"),
n("branch", "root", "a1"),
n("leaf", "branch", "a1"),
];
}
function renderTree(store: ReturnType<typeof createStore>) {
const ref = createRef<SpaceTreeApi>();
render(
<Provider store={store}>
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
</Provider>,
);
return ref;
}
beforeEach(() => {
getSpaceTreeMock.mockReset();
notificationsShowMock.mockReset();
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
// fresh jotai store anyway, so cross-test open-state never leaks.
try {
localStorage.clear?.();
} catch {
/* ignore — fresh store per test isolates state */
}
});
afterEach(() => {
cleanup();
});
describe("SpaceTree.expandAll (integration via ref)", () => {
it("makes exactly ONE getSpaceTree call regardless of depth (no N+1)", async () => {
getSpaceTreeMock.mockResolvedValue(spaceTreeItems());
const store = createStore();
const ref = renderTree(store);
await ref.current!.expandAll();
expect(getSpaceTreeMock).toHaveBeenCalledTimes(1);
expect(getSpaceTreeMock).toHaveBeenCalledWith({ spaceId: "space-1" });
// Every branch node (root, branch) is opened; the leaf needs no entry.
const openMap = store.get(openTreeNodesAtom);
expect(openMap["root"]).toBe(true);
expect(openMap["branch"]).toBe(true);
expect(openMap["leaf"]).toBeUndefined();
// The full tree replaced the current-space nodes.
const data = store.get(treeDataAtom);
expect(data.map((d) => d.id)).toEqual(["root"]);
});
it("shows a notification and still resets isExpanding when getSpaceTree rejects", async () => {
getSpaceTreeMock.mockRejectedValue(new Error("boom"));
const store = createStore();
const ref = renderTree(store);
await ref.current!.expandAll();
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ color: "red" }),
);
// isExpanding must be reset in the finally block even on failure.
await waitFor(() => {
expect(ref.current!.isExpanding).toBe(false);
});
});
it("aborts the merge when the space switches mid-flight", async () => {
// getSpaceTree resolves only after we flip the tree to a different space,
// simulating the user navigating away while the request is in flight.
let resolveTree: (v: SpaceTreeNode[]) => void = () => {};
getSpaceTreeMock.mockImplementation(
() =>
new Promise<SpaceTreeNode[]>((resolve) => {
resolveTree = resolve;
}),
);
const store = createStore();
const ref = createRef<SpaceTreeApi>();
const { rerender } = render(
<Provider store={store}>
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
</Provider>,
);
const promise = ref.current!.expandAll();
// Switch the space mid-flight: spaceIdRef.current becomes "space-2".
rerender(
<Provider store={store}>
<SpaceTree ref={ref} spaceId="space-2" readOnly={false} />
</Provider>,
);
// Now resolve the in-flight request for the OLD space.
resolveTree(spaceTreeItems());
await promise;
// The merge must have been aborted: no tree data written, no branches opened.
expect(store.get(treeDataAtom)).toEqual([]);
const openMap = store.get(openTreeNodesAtom);
expect(openMap["root"]).toBeUndefined();
expect(openMap["branch"]).toBeUndefined();
});
});

View File

@@ -27,6 +27,8 @@ import {
mergeRootTrees,
collectAllIds,
collectBranchIds,
openBranches,
closeIds,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -236,11 +238,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
// Open every branch node (node with children) of the current space only.
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
} catch (err: any) {
// Never swallow: log full error + surface the real reason.
console.error("[tree] expandAll failed", err);
@@ -261,11 +259,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
// other spaces' expanded state is left intact.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
setOpenTreeNodes((prev) => closeIds(prev, ids));
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(

View File

@@ -196,6 +196,17 @@ describe('treeModel.insertByPosition', () => {
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
});
it('tie-break: a node whose position EQUALS a sibling lands deterministically (strict >)', () => {
// The insertion index is the first sibling whose position sorts STRICTLY
// after the new node's. An equal sibling is not strictly after, so it is
// skipped — the new node lands immediately AFTER every equal-position
// sibling and before the first strictly-greater one. This is deterministic:
// a tie always resolves the same way on every client.
const node: P = { id: 'x', name: 'X', position: 'a2' }; // equals b's position
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
});
});
// addTreeNode idempotency: the receiver early-returns when the node id already
@@ -692,4 +703,45 @@ describe('treeModel.move', () => {
});
expect(out.tree).toBe(fixture);
});
it('cross-parent move does NOT apply the same-parent adjust (no off-by-one)', () => {
// Source `x3` sits at index 2 in parent `x`; target `y1` sits at index 0 in
// parent `y`. sourceInfo.index (2) > info.index (0) AND the parents differ,
// so the `sameParent && source.index < info.index` adjust must be 0 — the
// node must land at index 0 in `y`, not at index -1 (which would silently
// drop it at a wrong slot / off-by-one).
const crossFixture: N[] = [
{
id: 'x',
name: 'X',
children: [
{ id: 'x1', name: 'X1' },
{ id: 'x2', name: 'X2' },
{ id: 'x3', name: 'X3' },
],
},
{
id: 'y',
name: 'Y',
children: [
{ id: 'y1', name: 'Y1' },
{ id: 'y2', name: 'Y2' },
],
},
];
const { tree: t, result } = treeModel.move(crossFixture, 'x3', {
kind: 'reorder-before',
targetId: 'y1',
});
expect(result).toEqual({ parentId: 'y', index: 0 });
expect(treeModel.find(t, 'y')?.children?.map((n) => n.id)).toEqual([
'x3',
'y1',
'y2',
]);
expect(treeModel.find(t, 'x')?.children?.map((n) => n.id)).toEqual([
'x1',
'x2',
]);
});
});

View File

@@ -1,6 +1,14 @@
import { describe, it, expect } from "vitest";
import { buildTree } from "./utils";
import {
buildTree,
buildTreeWithChildren,
collectAllIds,
collectBranchIds,
openBranches,
closeIds,
} from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
function page(id: string, position: string): IPage {
return {
@@ -15,6 +23,44 @@ function page(id: string, position: string): IPage {
} as IPage;
}
// Flat SpaceTreeNode factory for buildTreeWithChildren (it consumes a flat list
// with parentPageId pointers and nests them).
function flatNode(
id: string,
parentPageId: string | null,
position: string,
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position,
spaceId: "space-1",
parentPageId: parentPageId as unknown as string,
hasChildren: false,
children: [],
};
}
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
function treeNode(
id: string,
children: SpaceTreeNode[] = [],
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: children.length > 0,
children,
};
}
describe("buildTree", () => {
it("builds one node per unique page", () => {
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
@@ -38,3 +84,192 @@ describe("buildTree", () => {
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("collectBranchIds", () => {
it("returns every node-with-children id in a multi-level tree", () => {
const tree = [
treeNode("root", [
treeNode("branch1", [treeNode("leaf1")]),
treeNode("leaf2"),
]),
treeNode("root2", [treeNode("leaf3")]),
];
expect(collectBranchIds(tree).sort()).toEqual([
"branch1",
"root",
"root2",
]);
});
it("returns [] for a leaf-only tree", () => {
const tree = [treeNode("a"), treeNode("b"), treeNode("c")];
expect(collectBranchIds(tree)).toEqual([]);
});
it("does NOT include a node whose children is an empty array", () => {
// hasChildren-less / empty-children nodes are leaves for expansion purposes.
const tree = [treeNode("a", [])];
expect(collectBranchIds(tree)).toEqual([]);
});
it("returns every ancestor id in a deep single chain", () => {
const chain = treeNode("a", [
treeNode("b", [treeNode("c", [treeNode("d")])]),
]);
// a, b, c are branches; d is the leaf.
expect(collectBranchIds([chain])).toEqual(["a", "b", "c"]);
});
it("returns [] for an empty tree", () => {
expect(collectBranchIds([])).toEqual([]);
});
});
describe("collectAllIds", () => {
it("returns every id (roots, branches, leaves)", () => {
const tree = [
treeNode("root", [
treeNode("branch1", [treeNode("leaf1")]),
treeNode("leaf2"),
]),
treeNode("root2"),
];
expect(collectAllIds(tree).sort()).toEqual([
"branch1",
"leaf1",
"leaf2",
"root",
"root2",
]);
});
it("returns every id in a deep chain", () => {
const chain = treeNode("a", [
treeNode("b", [treeNode("c", [treeNode("d")])]),
]);
expect(collectAllIds([chain])).toEqual(["a", "b", "c", "d"]);
});
it("returns [] for an empty tree", () => {
expect(collectAllIds([])).toEqual([]);
});
it("is a superset of collectBranchIds for the same tree (property)", () => {
const tree = [
treeNode("root", [
treeNode("branch1", [treeNode("leaf1"), treeNode("leaf2")]),
treeNode("branch2", [treeNode("leaf3")]),
treeNode("leaf4"),
]),
treeNode("root2", [treeNode("leaf5")]),
];
const all = new Set(collectAllIds(tree));
const branches = collectBranchIds(tree);
for (const id of branches) {
expect(all.has(id)).toBe(true);
}
// And the superset is strictly larger (it also has the leaves).
expect(all.size).toBeGreaterThan(branches.length);
});
});
describe("buildTreeWithChildren", () => {
it("nests a flat list and sorts siblings by position", () => {
// Provided out of position order to prove the sort.
const flat = [
flatNode("root", null, "a0"),
flatNode("c2", "root", "a4"),
flatNode("c1", "root", "a1"),
];
const tree = buildTreeWithChildren(flat);
expect(tree.map((n) => n.id)).toEqual(["root"]);
expect(tree[0].children.map((n) => n.id)).toEqual(["c1", "c2"]);
});
it("recomputes hasChildren to true for nodes that gain children", () => {
// Parent ships with hasChildren=false; building must flip it true.
const flat = [
flatNode("root", null, "a0"),
flatNode("child", "root", "a1"),
];
expect(flat[0].hasChildren).toBe(false);
const tree = buildTreeWithChildren(flat);
expect(tree[0].hasChildren).toBe(true);
});
it("treats a node whose parentPageId is ABSENT from the list as a root (no crash)", () => {
// Permission-trimmed response: `orphan`'s parent `missing` was filtered out
// server-side. The function must not throw and must surface the orphan as a
// root rather than dropping or crashing on it.
const flat = [
flatNode("root", null, "a0"),
flatNode("orphan", "missing", "a2"),
];
let tree: SpaceTreeNode[] = [];
expect(() => {
tree = buildTreeWithChildren(flat);
}).not.toThrow();
expect(tree.map((n) => n.id).sort()).toEqual(["orphan", "root"]);
});
});
describe("openBranches", () => {
it("sets all given ids to true", () => {
const next = openBranches({}, ["a", "b", "c"]);
expect(next).toEqual({ a: true, b: true, c: true });
});
it("preserves pre-existing open ids and other-space ids", () => {
const prev = { existing: true, "other-space": true, closed: false };
const next = openBranches(prev, ["a"]);
expect(next).toEqual({
existing: true,
"other-space": true,
closed: false,
a: true,
});
});
it("does not mutate the input map", () => {
const prev = { a: false };
const next = openBranches(prev, ["a"]);
expect(prev).toEqual({ a: false });
expect(next).not.toBe(prev);
});
it("is idempotent", () => {
const once = openBranches({ z: true }, ["a", "b"]);
const twice = openBranches(once, ["a", "b"]);
expect(twice).toEqual(once);
});
});
describe("closeIds", () => {
it("flips current-space ids to false while leaving OTHER-space ids untouched", () => {
const prev = {
"current-1": true,
"current-2": true,
"other-space": true,
};
const next = closeIds(prev, ["current-1", "current-2"]);
expect(next).toEqual({
"current-1": false,
"current-2": false,
"other-space": true, // untouched
});
});
it("does not mutate the input map", () => {
const prev = { a: true };
const next = closeIds(prev, ["a"]);
expect(prev).toEqual({ a: true });
expect(next).not.toBe(prev);
});
it("is idempotent", () => {
const once = closeIds({ keep: true }, ["a", "b"]);
const twice = closeIds(once, ["a", "b"]);
expect(twice).toEqual(once);
expect(twice).toEqual({ keep: true, a: false, b: false });
});
});

View File

@@ -142,11 +142,17 @@ export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
// Build the tree array
items.forEach((item) => {
const node = nodeMap[item.id];
if (item.parentPageId !== null) {
// A permission-trimmed response can include a node whose `parentPageId` is
// not in the list (the parent was filtered out server-side). Treat such an
// orphan as a root instead of dereferencing an absent parent and throwing
// "Cannot read properties of undefined". Happy-path behaviour is unchanged:
// a node whose parent IS present still nests under it.
if (item.parentPageId !== null && nodeMap[item.parentPageId]) {
// Find the parent node and add the current node to its children
nodeMap[item.parentPageId].children.push(node);
} else {
// If the item has no parent, it's a root node, so add it to the result array
// If the item has no parent (or its parent isn't loaded), it's a root
// node, so add it to the result array.
result.push(node);
}
});
@@ -254,3 +260,30 @@ export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
walk(nodes);
return ids;
}
// The open-state map (`openTreeNodesAtom`) is shared across spaces. Pure
// next-map helpers for expand/collapse so the merge logic can be unit-tested
// without rendering SpaceTree. Both return a fresh map and never mutate the
// input — ids not in `ids` (e.g. other spaces) are carried over untouched.
// Set each id in `ids` to true (open). Pre-existing entries (including other
// spaces' open state) are preserved.
export function openBranches(
prevMap: Record<string, boolean>,
ids: string[],
): Record<string, boolean> {
const next = { ...prevMap };
for (const id of ids) next[id] = true;
return next;
}
// Set each id in `ids` to false (closed). Entries not listed (e.g. other
// spaces' ids) are left exactly as they were.
export function closeIds(
prevMap: Record<string, boolean>,
ids: string[],
): Record<string, boolean> {
const next = { ...prevMap };
for (const id of ids) next[id] = false;
return next;
}

View File

@@ -0,0 +1,264 @@
import { describe, it, expect } from "vitest";
import {
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
} from "./tree-socket-reducers";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
// Minimal node factory — fills the SpaceTreeNode shape required fields while
// letting tests override the bits that matter (position, parentPageId, etc).
function node(
id: string,
overrides: Partial<SpaceTreeNode> = {},
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...overrides,
};
}
describe("applyMoveTreeNode", () => {
// Destination parent `dst` is loaded with three positioned children; the moved
// node `src` is a sibling at root with a later position.
const buildTree = (): SpaceTreeNode[] => [
node("dst", {
position: "a0",
hasChildren: true,
children: [
node("c1", { position: "a1", parentPageId: "dst" }),
node("c2", { position: "a3", parentPageId: "dst" }),
node("c3", { position: "a5", parentPageId: "dst" }),
],
}),
node("src", { position: "a9" }),
];
it("places the node by position in the MIDDLE slot of the destination", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"src",
"c3",
]);
});
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "not-loaded",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// The source must not linger at its old place — it is removed entirely.
expect(treeModel.find(next, "src")).toBeNull();
// Destination children are untouched.
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"c3",
]);
});
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
// src is the only child of `old`; moving it to `dst` empties `old`.
const tree: SpaceTreeNode[] = [
node("old", {
position: "a0",
hasChildren: true,
children: [node("src", { position: "a1", parentPageId: "old" })],
}),
node("dst", { position: "a2", hasChildren: false }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: "old",
index: 0,
position: "a1",
pageData: {},
});
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
});
it("flips the NEW parent's hasChildren to true", () => {
// dst starts as a childless leaf; moving src into it must flip the chevron.
const tree: SpaceTreeNode[] = [
node("dst", { position: "a0", hasChildren: false }),
node("src", { position: "a9" }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a1",
pageData: {},
});
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"src",
]);
});
it("returns prev unchanged when the source node is not found", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "ghost",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
expect(next).toBe(tree);
});
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
});
const moved = treeModel.find(next, "src");
expect(moved?.name).toBe("Renamed");
expect(moved?.icon).toBe("fire");
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
});
describe("applyDeleteTreeNode", () => {
it("removes the node together with its descendants", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [
node("child", {
position: "a1",
parentPageId: "p",
hasChildren: true,
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
}),
],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("child", { parentPageId: "p" }),
});
expect(treeModel.find(next, "child")).toBeNull();
expect(treeModel.find(next, "grandchild")).toBeNull();
expect(treeModel.find(next, "p")).not.toBeNull();
});
it("returns prev unchanged when the node is already gone (idempotent)", () => {
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
const next = applyDeleteTreeNode(tree, {
node: node("ghost"),
});
expect(next).toBe(tree);
});
it("flips the parent's hasChildren to false when it is left childless", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [node("only", { position: "a1", parentPageId: "p" })],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("only", { parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
expect(treeModel.find(next, "p")?.children).toEqual([]);
});
it("leaves the parent's hasChildren true when other children remain", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [
node("c1", { position: "a1", parentPageId: "p" }),
node("c2", { position: "a2", parentPageId: "p" }),
],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("c1", { parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
});
});
describe("applyAddTreeNode", () => {
const roots = (): SpaceTreeNode[] => [
node("a", { position: "a0" }),
node("b", { position: "a2" }),
node("c", { position: "a4" }),
];
it("inserts the new node by position among siblings", () => {
const tree = roots();
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("x", { position: "a3" }),
});
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
});
it("returns prev unchanged when the id is already present (idempotent)", () => {
const tree = roots();
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("b", { position: "a9" }),
});
expect(next).toBe(tree);
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
});
it("flips the new parent's hasChildren to true", () => {
// Parent `p` is a childless leaf; adding a child must flip its chevron.
const tree: SpaceTreeNode[] = [
node("p", { position: "a0", hasChildren: false }),
];
const next = applyAddTreeNode(tree, {
parentId: "p",
index: 0,
data: node("child", { position: "a1", parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
"child",
]);
});
});

View File

@@ -0,0 +1,164 @@
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import type {
AddTreeNodeEvent,
MoveTreeNodeEvent,
DeleteTreeNodeEvent,
UpdateEvent,
} from "@/features/websocket/types";
// Pure tree transforms for the `useTreeSocket` reducer arms. Extracted from the
// hook so the realtime tree behaviour can be unit-tested without rendering the
// hook, the socket, or jotai. The hook calls these inside its `setData`.
//
// IMPORTANT: these are PURE — no `queryClient`, no notifications, no atoms. The
// delete arm's `queryClient.invalidateQueries` side effect stays in the hook;
// `applyDeleteTreeNode` is a pure tree transform only.
// `updateOne` for a page: patch the in-tree node's name/icon from the payload.
// No-op (returns the same reference) when the node isn't loaded on this client.
export function applyUpdateOne(
prev: SpaceTreeNode[],
event: UpdateEvent,
): SpaceTreeNode[] {
if (!treeModel.find(prev, event.id)) return prev;
let next = prev;
if (event.payload?.title !== undefined) {
next = treeModel.update(next, event.id, {
name: event.payload.title,
} as Partial<SpaceTreeNode>);
}
if (event.payload?.icon !== undefined) {
next = treeModel.update(next, event.id, {
icon: event.payload.icon,
} as Partial<SpaceTreeNode>);
}
return next;
}
// `addTreeNode`: insert the new node by its fractional `position` among the
// already-loaded siblings (not the sender's absolute index). Idempotent — if the
// id already exists (optimistic author insert or re-delivery) returns prev
// unchanged. Flips the new parent's `hasChildren` to true so the chevron renders.
export function applyAddTreeNode(
prev: SpaceTreeNode[],
payload: AddTreeNodeEvent["payload"],
): SpaceTreeNode[] {
// Idempotent: the author already inserted the node optimistically, and a node
// may be re-delivered — never insert a duplicate id.
if (treeModel.find(prev, payload.data.id)) return prev;
const newParentId = payload.parentId as string | null;
// Insert by `position` among already-loaded siblings (not the sender's
// absolute index) so order is consistent across clients with different loaded
// sets.
let next = treeModel.insertByPosition(prev, newParentId, payload.data);
// Mirror the emitter: flip new parent's hasChildren to true so the chevron
// renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
}
// `moveTreeNode`: place the moved node by its fractional `position` among the new
// siblings (NOT the sender's absolute index). If the destination parent isn't
// loaded on this client, fall back to removing the source so the UI stays
// consistent. Applies authoritative `pageData` fields and mirrors the
// `hasChildren` bookkeeping for both the old and the new parent.
export function applyMoveTreeNode(
prev: SpaceTreeNode[],
payload: MoveTreeNodeEvent["payload"],
): SpaceTreeNode[] {
const sourceBefore = treeModel.find(prev, payload.id);
if (!sourceBefore) return prev;
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = payload.parentId as string | null;
// Place the node by its fractional `position` among the new siblings — NOT by
// the sender's absolute `index` (the sender computed that against its own
// loaded set, which differs from this receiver's). Using the position keeps
// the visible order correct on every client; placing at `index: 0` would
// wrongly drop reordered/moved nodes at the top of their new sibling list.
const placed = treeModel.placeByPosition(prev, payload.id, {
parentId: newParentId,
position: payload.position,
});
// `placeByPosition` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the source
// keeps the UI consistent (the source reappears when the user expands the new
// parent and lazy-load fetches it).
if (placed === prev) {
return treeModel.remove(prev, payload.id);
}
// Apply the authoritative node fields the move payload carries (`pageData`) so
// receivers don't keep a stale title/icon/chevron on the moved node.
// `placeByPosition` already set `position`.
const pageData = payload.pageData as
| {
title?: string | null;
icon?: string | null;
hasChildren?: boolean;
}
| undefined;
const patch: Partial<SpaceTreeNode> = {
position: payload.position,
// Honest type: a root move has a null parent, so this is `string | null`,
// not always `string`.
parentPageId: newParentId as string | null,
};
if (pageData) {
// The tree node stores the title as `name`.
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
if (pageData.icon !== undefined) patch.icon = pageData.icon ?? undefined;
if (pageData.hasChildren !== undefined)
patch.hasChildren = pageData.hasChildren;
}
let next = treeModel.update(placed, payload.id, patch);
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
// same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
}
// `deleteTreeNode`: remove the node (and its descendants) from the tree.
// Idempotent — if the node is already gone returns prev unchanged. Mirrors the
// `hasChildren` bookkeeping: a parent left childless flips `hasChildren` false.
//
// PURE: the `queryClient.invalidateQueries` side effect lives in the hook, not
// here.
export function applyDeleteTreeNode(
prev: SpaceTreeNode[],
payload: DeleteTreeNodeEvent["payload"],
): SpaceTreeNode[] {
if (!treeModel.find(prev, payload.node.id)) return prev;
let next = treeModel.remove(prev, payload.node.id);
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
// same chevron state when the last child is deleted.
const parentPageId = payload.node.parentPageId;
if (parentPageId) {
const parent = treeModel.find(next, parentPageId);
if (!parent?.children?.length) {
next = treeModel.update(next, parentPageId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
return next;
}

View File

@@ -6,6 +6,12 @@ import { WebSocketEvent } from "@/features/websocket/types";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useQueryClient } from "@tanstack/react-query";
import { treeModel } from "@/features/page/tree/model/tree-model";
import {
applyUpdateOne,
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
} from "@/features/websocket/tree-socket-reducers.ts";
import localEmitter from "@/lib/local-emitter.ts";
export const useTreeSocket = () => {
@@ -35,138 +41,26 @@ export const useTreeSocket = () => {
switch (event.operation) {
case "updateOne":
if (event.entity[0] === "pages") {
setTreeData((prev) => {
if (!treeModel.find(prev, event.id)) return prev;
let next = prev;
if (event.payload?.title !== undefined) {
next = treeModel.update(next, event.id, {
name: event.payload.title,
} as Partial<SpaceTreeNode>);
}
if (event.payload?.icon !== undefined) {
next = treeModel.update(next, event.id, {
icon: event.payload.icon,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData((prev) => applyUpdateOne(prev, event));
}
break;
case "addTreeNode":
setTreeData((prev) => {
// Idempotent: the author already inserted the node optimistically,
// and a node may be re-delivered — never insert a duplicate id.
if (treeModel.find(prev, event.payload.data.id)) return prev;
const newParentId = event.payload.parentId as string | null;
// Insert by `position` among already-loaded siblings (not the
// sender's absolute index) so order is consistent across clients
// with different loaded sets.
let next = treeModel.insertByPosition(
prev,
newParentId,
event.payload.data,
);
// Mirror the emitter: flip new parent's hasChildren to true so
// the chevron renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
break;
case "moveTreeNode":
setTreeData((prev) => {
const sourceBefore = treeModel.find(prev, event.payload.id);
if (!sourceBefore) return prev;
const oldParentId =
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
// Place the node by its fractional `position` among the new
// siblings — NOT by the sender's absolute `index` (the sender
// computed that against its own loaded set, which differs from
// this receiver's). Using the position keeps the visible order
// correct on every client; placing at `index: 0` would wrongly
// drop reordered/moved nodes at the top of their new sibling list.
const placed = treeModel.placeByPosition(prev, event.payload.id, {
parentId: newParentId,
position: event.payload.position,
});
// `placeByPosition` silently returns the same reference if the
// destination parent isn't loaded on this client. Falling back to
// removing the source keeps the UI consistent (the source will
// reappear when the user expands the new parent and lazy-load
// fetches it).
if (placed === prev) {
return treeModel.remove(prev, event.payload.id);
}
// Apply the authoritative node fields the move payload carries
// (`pageData`) so receivers don't keep a stale title/icon/chevron
// on the moved node. `placeByPosition` already set `position`.
const pageData = event.payload.pageData as
| {
title?: string | null;
icon?: string | null;
hasChildren?: boolean;
}
| undefined;
const patch: Partial<SpaceTreeNode> = {
position: event.payload.position,
// Honest type: a root move has a null parent, so this is
// `string | null`, not always `string`.
parentPageId: newParentId as string | null,
};
if (pageData) {
// The tree node stores the title as `name`.
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
if (pageData.icon !== undefined)
patch.icon = pageData.icon ?? undefined;
if (pageData.hasChildren !== undefined)
patch.hasChildren = pageData.hasChildren;
}
let next = treeModel.update(placed, event.payload.id, patch);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
break;
case "deleteTreeNode":
// The `invalidateQueries` side effect stays in the hook; the tree
// transform (`applyDeleteTreeNode`) is pure. Only invalidate when the
// node is actually in the tree (mirrors the pure reducer's early-out).
setTreeData((prev) => {
if (!treeModel.find(prev, event.payload.node.id)) return prev;
queryClient.invalidateQueries({
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
});
let next = treeModel.remove(prev, event.payload.node.id);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state when the last child is deleted.
const parentPageId = event.payload.node.parentPageId;
if (parentPageId) {
const parent = treeModel.find(next, parentPageId);
if (!parent?.children?.length) {
next = treeModel.update(next, parentPageId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
if (treeModel.find(prev, event.payload.node.id)) {
queryClient.invalidateQueries({
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
});
}
return next;
return applyDeleteTreeNode(prev, event.payload);
});
break;
}

View File

@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { resolveCardStatus } from './ai-provider-settings';
import {
resolveCardStatus,
isEndpointConfigured,
resolveKeyField,
} from './ai-provider-settings';
describe('resolveCardStatus', () => {
it('returns "off" when not configured and not enabled', () => {
@@ -18,3 +22,52 @@ describe('resolveCardStatus', () => {
expect(resolveCardStatus(true, true)).toBe('ready');
});
});
describe('isEndpointConfigured', () => {
it('configured when model and the endpoint own base URL are set', () => {
expect(isEndpointConfigured('m', 'https://own', '')).toBe(true);
});
it('configured by inheriting the chat base URL when own base is empty', () => {
expect(isEndpointConfigured('m', '', 'https://chat')).toBe(true);
});
it('not configured when model is set but both base URLs are empty', () => {
expect(isEndpointConfigured('m', '', '')).toBe(false);
});
it('not configured when both base URLs are whitespace-only', () => {
expect(isEndpointConfigured('m', ' ', '\t')).toBe(false);
});
it('not configured when the model is whitespace-only', () => {
expect(isEndpointConfigured(' ', 'https://own', 'https://chat')).toBe(
false,
);
});
});
describe('resolveKeyField (write-only key payload)', () => {
// The same logic backs all three keys (chat / embedding / stt) in buildPayload.
it('typed a value -> set the new key', () => {
expect(resolveKeyField('sk-new', false)).toEqual({
set: true,
value: 'sk-new',
});
});
it('typed a value wins even if cleared was also flagged', () => {
expect(resolveKeyField('sk-new', true)).toEqual({
set: true,
value: 'sk-new',
});
});
it('cleared (empty buffer) -> set the key to empty string', () => {
expect(resolveKeyField('', true)).toEqual({ set: true, value: '' });
});
it('untouched (empty buffer, not cleared) -> omit the key', () => {
expect(resolveKeyField('', false)).toEqual({ set: false });
});
});

View File

@@ -98,6 +98,33 @@ export function resolveCardStatus(
return enabled ? "warning" : "off";
}
// Pure + unit-testable. A non-chat endpoint (embeddings / voice) is "configured"
// when its model is set AND it has a usable base URL: either its own base URL is
// non-empty, or the chat base URL is non-empty (inherited when own is empty).
// All inputs are trimmed so whitespace-only values do not count as filled.
export function isEndpointConfigured(
model: string,
ownBase: string,
chatBase: string,
): boolean {
return (
model.trim() !== "" && (ownBase.trim() !== "" || chatBase.trim() !== "")
);
}
// Pure + unit-testable. Write-only API-key payload semantics:
// - typed a value (buffer non-empty) -> set it
// - explicitly cleared -> send '' to clear the stored key
// - untouched (empty buffer, not cleared) -> omit the key entirely
export function resolveKeyField(
buffer: string,
cleared: boolean,
): { set: true; value: string } | { set: false } {
if (buffer.length > 0) return { set: true, value: buffer };
if (cleared) return { set: true, value: "" };
return { set: false };
}
// Translate the dot's tooltip label. Kept in one place so all three endpoint
// cards share identical wording.
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
@@ -263,29 +290,23 @@ export default function AiProviderSettings() {
sttApiStyle: values.sttApiStyle,
};
// Key semantics (never send the stored key back):
// Key semantics (never send the stored key back) — see resolveKeyField:
// - typed a value -> set it
// - explicitly cleared -> send '' to clear
// - untouched -> omit the key entirely (leave unchanged)
if (values.apiKey.length > 0) {
payload.apiKey = values.apiKey;
} else if (keyCleared) {
payload.apiKey = "";
}
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
// Same write-only semantics for the embedding-specific key.
if (values.embeddingApiKey.length > 0) {
payload.embeddingApiKey = values.embeddingApiKey;
} else if (embeddingKeyCleared) {
payload.embeddingApiKey = "";
}
const embeddingKeyField = resolveKeyField(
values.embeddingApiKey,
embeddingKeyCleared,
);
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
// Same write-only semantics for the STT-specific key.
if (values.sttApiKey.length > 0) {
payload.sttApiKey = values.sttApiKey;
} else if (sttKeyCleared) {
payload.sttApiKey = "";
}
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
return payload;
}
@@ -460,12 +481,12 @@ export default function AiProviderSettings() {
const v = form.values;
const chatBase = v.baseUrl.trim();
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
const embedConfigured =
v.embeddingModel.trim() !== "" &&
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
const sttConfigured =
v.sttModel.trim() !== "" &&
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
const embedConfigured = isEndpointConfigured(
v.embeddingModel,
v.embeddingBaseUrl,
v.baseUrl,
);
const sttConfigured = isEndpointConfigured(v.sttModel, v.sttBaseUrl, v.baseUrl);
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);