Merge remote-tracking branch 'gitea/develop' into HEAD
# Conflicts: # CHANGELOG.md # packages/mcp/build/index.js # packages/mcp/build/lib/auth-utils.js # packages/mcp/build/lib/docmost-schema.js # packages/mcp/build/lib/markdown-converter.js
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconEyeOff,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
@@ -74,6 +75,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
isStrike: ctx.editor.isActive("strike"),
|
||||
isCode: ctx.editor.isActive("code"),
|
||||
isComment: ctx.editor.isActive("comment"),
|
||||
isSpoiler: ctx.editor.isActive("spoiler"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -109,6 +111,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||
icon: IconCode,
|
||||
},
|
||||
{
|
||||
name: "Spoiler",
|
||||
isActive: () => editorState?.isSpoiler,
|
||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||
icon: IconEyeOff,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlt } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
|
||||
|
||||
const ALT_MAX_LENGTH = 300;
|
||||
|
||||
@@ -27,113 +18,25 @@ type UseAltTextControlArgs = {
|
||||
currentAlt: string;
|
||||
};
|
||||
|
||||
// Thin wrapper over the shared image text-field popover; see
|
||||
// useImageTextFieldControl. The t("...") literals stay here so they remain
|
||||
// statically extractable for i18n.
|
||||
export function useAltTextControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentAlt,
|
||||
}: UseAltTextControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentAlt || "");
|
||||
setShowInput(true);
|
||||
}, [currentAlt]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Alt text")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconAlt size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Alt text")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Describe this for accessibility.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a description")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={ALT_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{ALT_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentAlt,
|
||||
attrName: "alt",
|
||||
sanitize: sanitizeAlt,
|
||||
maxLength: ALT_MAX_LENGTH,
|
||||
icon: <IconAlt size={18} />,
|
||||
label: t("Alt text"),
|
||||
description: t("Describe this for accessibility."),
|
||||
placeholder: t("Add a description"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeCaption } from "@/features/editor/components/common/use-caption-control.tsx";
|
||||
|
||||
/**
|
||||
* `sanitizeCaption` = collapse every whitespace run to a single space + trim +
|
||||
* cap at 500 chars. Captions are plain visible text, so this is a softer
|
||||
* normalization than alt-text sanitization.
|
||||
*/
|
||||
describe("sanitizeCaption", () => {
|
||||
it("trims leading and trailing whitespace", () => {
|
||||
expect(sanitizeCaption(" hello ")).toBe("hello");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace runs to a single space", () => {
|
||||
expect(sanitizeCaption("a b c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("treats tab, newline and CRLF as whitespace", () => {
|
||||
expect(sanitizeCaption("a\tb")).toBe("a b");
|
||||
expect(sanitizeCaption("a\nb")).toBe("a b");
|
||||
expect(sanitizeCaption("a\r\nb")).toBe("a b");
|
||||
expect(sanitizeCaption("line1\n\n\nline2")).toBe("line1 line2");
|
||||
});
|
||||
|
||||
it("treats unicode whitespace (no-break space) as a separator", () => {
|
||||
// U+00A0 NO-BREAK SPACE is matched by the \s class.
|
||||
expect(sanitizeCaption("a b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(sanitizeCaption(" ")).toBe("");
|
||||
expect(sanitizeCaption("")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a caption at the 500-char limit unchanged", () => {
|
||||
const exact = "x".repeat(500);
|
||||
expect(sanitizeCaption(exact)).toHaveLength(500);
|
||||
expect(sanitizeCaption(exact)).toBe(exact);
|
||||
});
|
||||
|
||||
it("slices a caption longer than 500 chars down to 500", () => {
|
||||
const tooLong = "y".repeat(600);
|
||||
const result = sanitizeCaption(tooLong);
|
||||
expect(result).toHaveLength(500);
|
||||
expect(result).toBe("y".repeat(500));
|
||||
});
|
||||
|
||||
it("collapses whitespace before applying the 500-char cap", () => {
|
||||
// 120 "a b " groups (600 raw chars) collapse to "a b a b ..." = 479 chars
|
||||
// after trimming the trailing space, which stays under the 500 cap — so only
|
||||
// the collapse is exercised here, no slice. (See the dedicated >500 test
|
||||
// above for the slice boundary.)
|
||||
const input = "a b ".repeat(120); // lots of double spaces
|
||||
const result = sanitizeCaption(input);
|
||||
expect(result).toHaveLength(479);
|
||||
expect(result.length).toBeLessThanOrEqual(500);
|
||||
expect(result).not.toMatch(/\s{2,}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { IconTextCaption } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
|
||||
|
||||
const CAPTION_MAX_LENGTH = 500;
|
||||
|
||||
// Caption is plain visible text (not a markdown link target like alt), so it is
|
||||
// sanitized more softly than alt: collapse runs of whitespace/newlines into a
|
||||
// single space and trim, keeping the limit generous.
|
||||
export function sanitizeCaption(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
type UseCaptionControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentCaption: string;
|
||||
};
|
||||
|
||||
// Thin wrapper over the shared image text-field popover; see
|
||||
// useImageTextFieldControl. The t("...") literals stay here so they remain
|
||||
// statically extractable for i18n.
|
||||
export function useCaptionControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentCaption,
|
||||
}: UseCaptionControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentCaption,
|
||||
attrName: "caption",
|
||||
sanitize: sanitizeCaption,
|
||||
maxLength: CAPTION_MAX_LENGTH,
|
||||
icon: <IconTextCaption size={18} />,
|
||||
label: t("Caption"),
|
||||
description: t("Shown below the image."),
|
||||
placeholder: t("Add a caption"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Shared logic+UI for the image bubble-menu text-field popovers (alt text,
|
||||
// caption, ...). Each field is the same popover — an ActionIcon that opens a
|
||||
// titled Paper with a counted Textarea and Cancel/Save — differing only in the
|
||||
// node attribute it writes, its sanitizer, length cap, icon and labels. The
|
||||
// label/description/placeholder are passed already translated so the literal
|
||||
// t("...") calls stay in the thin wrappers and remain extractable; the shared
|
||||
// Cancel/Save strings are translated here.
|
||||
type UseImageTextFieldControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentValue: string;
|
||||
attrName: string;
|
||||
sanitize: (value: string) => string;
|
||||
maxLength: number;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export function useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue,
|
||||
attrName,
|
||||
sanitize,
|
||||
maxLength,
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
}: UseImageTextFieldControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentValue || "");
|
||||
setShowInput(true);
|
||||
}, [currentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { [attrName]: sanitize(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, attrName, sanitize, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={label} withinPortal={false}>
|
||||
<ActionIcon onClick={open} size="lg" aria-label={label} variant="subtle">
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{description}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{maxLength}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import { useCaptionControl } from "@/features/editor/components/common/use-caption-control.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
@@ -47,6 +48,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||
src: imageAttrs?.src || null,
|
||||
alt: imageAttrs?.alt || "",
|
||||
caption: imageAttrs?.caption || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -168,6 +170,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
currentAlt: editorState?.alt || "",
|
||||
});
|
||||
|
||||
const {
|
||||
button: captionButton,
|
||||
panel: captionPanel,
|
||||
isEditing: isEditingCaption,
|
||||
} = useCaptionControl({
|
||||
editor,
|
||||
nodeName: "image",
|
||||
currentCaption: editorState?.caption || "",
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -183,6 +195,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
>
|
||||
{isEditingAlt ? (
|
||||
altTextPanel
|
||||
) : isEditingCaption ? (
|
||||
captionPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
@@ -249,6 +263,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
{altTextButton}
|
||||
|
||||
{captionButton}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
|
||||
@@ -9,7 +9,9 @@ import { useTranslation } from "react-i18next";
|
||||
export default function ImageView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
|
||||
const { src, width, align, alt, caption, aspectRatio, placeholder } =
|
||||
node.attrs;
|
||||
const captionText = (caption || "").trim();
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
@@ -29,6 +31,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<figure style={{ margin: 0 }}>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
@@ -66,6 +69,15 @@ export default function ImageView(props: NodeViewProps) {
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
{captionText && (
|
||||
<Text
|
||||
component="figcaption"
|
||||
className="image-caption"
|
||||
>
|
||||
{captionText}
|
||||
</Text>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
|
||||
// Click-to-reveal spoiler. The revealed state is UI-only and is never written to
|
||||
// the document: toggling only adds/removes the `is-revealed` class (CSS removes
|
||||
// the blur). renderHTML never emits `is-revealed`, so it can't leak into the
|
||||
// doc/clipboard. Works the same in editor, read-only and public-share views.
|
||||
export default function SpoilerView(_props: MarkViewProps) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={revealed ? "spoiler is-revealed" : "spoiler"}
|
||||
data-spoiler="true"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
>
|
||||
<MarkViewContent />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
Subpages,
|
||||
Heading,
|
||||
Highlight,
|
||||
Spoiler,
|
||||
Indent,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
@@ -116,6 +117,7 @@ import mentionRenderItems from "@/features/editor/components/mention/mention-sug
|
||||
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
|
||||
import LinkView from "@/features/editor/components/link/link-view.tsx";
|
||||
import SpoilerView from "@/features/editor/components/spoiler/spoiler-view.tsx";
|
||||
import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
@@ -123,6 +125,7 @@ import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
|
||||
import { IntentionalClear } from "@/features/editor/extensions/intentional-clear.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -237,6 +240,11 @@ export const mainExtensions = [
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Spoiler.configure({}).extend({
|
||||
addMarkView() {
|
||||
return ReactMarkViewRenderer(SpoilerView);
|
||||
},
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
@@ -486,4 +494,10 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
color: randomElement(userColors),
|
||||
},
|
||||
}),
|
||||
// #251 — emit an intentional-clear signal to the server when the user
|
||||
// deliberately empties the page, so the #248 store-side empty-guard lets that
|
||||
// one clear through while still blocking accidental empties.
|
||||
IntentionalClear.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
import {
|
||||
IntentionalClear,
|
||||
INTENTIONAL_CLEAR_MESSAGE_TYPE,
|
||||
} from "./intentional-clear";
|
||||
|
||||
/**
|
||||
* #251 — the intentional-clear signal is driven through the REAL editor path:
|
||||
* a fresh Editor with the IntentionalClear extension, a fake provider that
|
||||
* records sendStateless, and the actual select-all + delete command the user's
|
||||
* keystroke runs. No hand-poke of any flag.
|
||||
*/
|
||||
describe("IntentionalClear extension", () => {
|
||||
let sendStateless: ReturnType<typeof vi.fn>;
|
||||
|
||||
const makeEditor = (content: unknown) =>
|
||||
new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
IntentionalClear.configure({
|
||||
// Minimal provider stand-in: only sendStateless is exercised.
|
||||
provider: { sendStateless } as any,
|
||||
}),
|
||||
],
|
||||
content: content as any,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateless = vi.fn();
|
||||
});
|
||||
|
||||
it("emits the clear signal when a user empties a non-empty doc (select-all + delete)", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "hello world" }] },
|
||||
],
|
||||
});
|
||||
|
||||
// The exact command path a select-all + Delete keystroke dispatches.
|
||||
editor.chain().selectAll().deleteSelection().run();
|
||||
|
||||
expect(sendStateless).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(sendStateless.mock.calls[0][0]);
|
||||
expect(payload).toEqual({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE });
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit when typing into an empty doc (no non-empty → empty transition)", () => {
|
||||
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
|
||||
editor.chain().insertContent("typed text").run();
|
||||
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit on an edit that leaves the doc non-empty", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "keep me" }] },
|
||||
],
|
||||
});
|
||||
|
||||
editor.chain().insertContent(" more").run();
|
||||
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit when a REMOTE/merge (change-origin) transaction empties the doc", () => {
|
||||
// This pins the CENTRAL #248 protection: only a LOCAL user edit may emit the
|
||||
// intentional-clear signal. An emptiness arriving from another client, a bad
|
||||
// merge, or an emptied transclusion is applied as a y-sync transaction tagged
|
||||
// with the ySyncPluginKey meta, which `isChangeOrigin` detects. The extension
|
||||
// must early-return on it and NOT punch the empty write through the server
|
||||
// guard.
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "remote content" }] },
|
||||
],
|
||||
});
|
||||
|
||||
// Build a transaction that empties the non-empty doc and tag it exactly the
|
||||
// way y-tiptap tags a remote y-sync update: `tr.setMeta(ySyncPluginKey,
|
||||
// { isChangeOrigin: true })` (see @tiptap/y-tiptap sync-plugin). This makes
|
||||
// the real `isChangeOrigin(tr)` predicate return true — not a stand-in.
|
||||
const { state } = editor;
|
||||
const tr = state.tr
|
||||
.delete(0, state.doc.content.size)
|
||||
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
// The transaction really emptied the doc (became the single empty paragraph)…
|
||||
expect(editor.state.doc.textContent).toBe("");
|
||||
// …yet because it is change-origin, no signal is emitted.
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT emit when the doc was already empty", () => {
|
||||
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
|
||||
// Selecting all + delete on an already-empty doc is a no-op transition.
|
||||
editor.chain().selectAll().deleteSelection().run();
|
||||
|
||||
expect(sendStateless).not.toHaveBeenCalled();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
|
||||
/**
|
||||
* Stateless message type sent to the server when a user deliberately clears a
|
||||
* page to empty. Kept in one place so the client emitter and the server
|
||||
* consumer (PersistenceExtension.onStateless) agree on the wire format.
|
||||
*/
|
||||
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = "intentional-clear";
|
||||
|
||||
export interface IntentionalClearOptions {
|
||||
/** The collab provider used to send the stateless clear signal. */
|
||||
provider: HocuspocusProvider | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A "document is empty" check that mirrors the server's `isEmptyParagraphDoc`
|
||||
* (collaboration.util.ts): exactly one top-level paragraph with no inline
|
||||
* content. After a select-all + delete TipTap leaves precisely this shape, so
|
||||
* matching it here keeps the client signal aligned with the server guard that
|
||||
* consumes it.
|
||||
*/
|
||||
function isEmptyParagraphDoc(doc: PMNode): boolean {
|
||||
if (doc.childCount !== 1) return false;
|
||||
const child = doc.firstChild;
|
||||
return (
|
||||
child !== null &&
|
||||
child !== undefined &&
|
||||
child.type.name === "paragraph" &&
|
||||
child.content.size === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #251 — intentional-clear signal.
|
||||
*
|
||||
* The server's #248 store-side empty-guard unconditionally refuses to overwrite
|
||||
* non-empty persisted content with an empty document, because a momentarily
|
||||
* empty live Y.Doc (a glitch, a bad merge, an emptying transclusion) is
|
||||
* indistinguishable from a real clear *at the store layer*. That protection is
|
||||
* correct, but it also blocks a user who genuinely wants to empty the page.
|
||||
*
|
||||
* This extension supplies the missing distinction. It watches LOCAL, user-driven
|
||||
* transactions and, the moment one reduces a non-empty document to the empty
|
||||
* single-paragraph shape, it sends a hocuspocus stateless message to the server.
|
||||
* The server records a short-lived, single-use "intentional clear pending" flag
|
||||
* for this document that the next (debounced) onStoreDocument consumes to let
|
||||
* that one empty write through the guard.
|
||||
*
|
||||
* What counts as an intentional clear (precise definition):
|
||||
* - the transaction actually changed the document (`docChanged`), AND
|
||||
* - it is a LOCAL user edit, not a remote collab application — remote y-sync
|
||||
* transactions are tagged and filtered out via `isChangeOrigin`, so an
|
||||
* emptiness that arrives from another client / a merge never emits a signal,
|
||||
* AND
|
||||
* - the document was non-empty before the transaction and is the empty
|
||||
* single-paragraph doc after it.
|
||||
*
|
||||
* This is exactly the select-all + Delete / Backspace (or any local command that
|
||||
* empties the doc, e.g. clearContent) keystroke path. A transient/programmatic
|
||||
* empty serialization that the server might see on the wire does NOT come with
|
||||
* this signal, so the guard still blocks it.
|
||||
*/
|
||||
export const IntentionalClear = Extension.create<IntentionalClearOptions>({
|
||||
name: "intentionalClear",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
provider: null,
|
||||
};
|
||||
},
|
||||
|
||||
onTransaction({ transaction }) {
|
||||
if (!transaction.docChanged) return;
|
||||
// Only react to local user edits. Remote collaboration steps (and other
|
||||
// y-sync-applied changes) carry the change origin and must never be treated
|
||||
// as an intentional clear, otherwise a remote/merge-induced emptiness would
|
||||
// punch through the server guard.
|
||||
if (isChangeOrigin(transaction)) return;
|
||||
|
||||
const becameEmpty =
|
||||
!isEmptyParagraphDoc(transaction.before) &&
|
||||
isEmptyParagraphDoc(transaction.doc);
|
||||
if (!becameEmpty) return;
|
||||
|
||||
// The server reads the originating document from the connection, so the
|
||||
// payload only needs to declare intent — it cannot target another document.
|
||||
this.options.provider?.sendStateless(
|
||||
JSON.stringify({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
@import "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./highlight.css";
|
||||
@import "./spoiler.css";
|
||||
@import "./indent.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
text-align: center;
|
||||
font-size: 0.875em;
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-top: 0.4em;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.uploading-text {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-md);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.spoiler {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 0.25em;
|
||||
cursor: pointer;
|
||||
filter: blur(0.3em);
|
||||
transition: filter 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.spoiler.is-revealed {
|
||||
filter: none;
|
||||
background: rgba(125, 125, 125, 0.18);
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.spoiler {
|
||||
filter: none;
|
||||
background: rgba(125, 125, 125, 0.18);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getTimeGroup,
|
||||
groupNotificationsByTime,
|
||||
} from "@/features/notification/notification.utils.ts";
|
||||
@@ -132,3 +134,59 @@ describe("groupNotificationsByTime", () => {
|
||||
expect(groupNotificationsByTime([], labels)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime — relative buckets and absolute-date fallback", () => {
|
||||
// Distinct fixed clock for the relative formatter (uses Date.now via `new
|
||||
// Date()`), so the bucket boundaries are deterministic under fake timers.
|
||||
const NOW = new Date("2026-06-15T12:00:00.000Z");
|
||||
const MIN = 60_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
// ISO string `ms` milliseconds before NOW.
|
||||
function ago(ms: number): string {
|
||||
return new Date(NOW.getTime() - ms).toISOString();
|
||||
}
|
||||
|
||||
it("returns the i18n 'now' label for anything under a minute", () => {
|
||||
expect(formatRelativeTime(ago(0))).toBe(i18n.t("now"));
|
||||
expect(formatRelativeTime(ago(59_000))).toBe(i18n.t("now"));
|
||||
});
|
||||
|
||||
it("crosses into the minutes bucket exactly at 1 minute", () => {
|
||||
expect(formatRelativeTime(ago(MIN - 1000))).toBe(i18n.t("now"));
|
||||
expect(formatRelativeTime(ago(MIN))).toBe("1m");
|
||||
expect(formatRelativeTime(ago(5 * MIN))).toBe("5m");
|
||||
expect(formatRelativeTime(ago(59 * MIN))).toBe("59m");
|
||||
});
|
||||
|
||||
it("crosses into the hours bucket exactly at 60 minutes", () => {
|
||||
expect(formatRelativeTime(ago(60 * MIN - 1000))).toBe("59m");
|
||||
expect(formatRelativeTime(ago(HOUR))).toBe("1h");
|
||||
expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h");
|
||||
});
|
||||
|
||||
it("crosses into the days bucket exactly at 24 hours", () => {
|
||||
expect(formatRelativeTime(ago(24 * HOUR - 1000))).toBe("23h");
|
||||
expect(formatRelativeTime(ago(DAY))).toBe("1d");
|
||||
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
|
||||
});
|
||||
|
||||
it("falls back to an absolute short date once >= 7 days old", () => {
|
||||
// 6d -> still relative; 7d -> absolute date (no longer N[mhd], and equal to
|
||||
// the localized short-date of the source timestamp).
|
||||
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
|
||||
|
||||
const sevenDaysAgo = ago(7 * DAY);
|
||||
const result = formatRelativeTime(sevenDaysAgo);
|
||||
expect(result).not.toMatch(/^\d+[mhd]$/);
|
||||
expect(result).not.toBe(i18n.t("now"));
|
||||
const expected = new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(sevenDaysAgo));
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findBreadcrumbPath } from "./utils";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// findBreadcrumbPath walks the live, SHARED sidebar tree. The high-value
|
||||
// invariant: when a node has no usable name it must surface "Untitled" ONLY on
|
||||
// the returned breadcrumb chain via a shallow copy — never by mutating the input
|
||||
// node (which would silently rename the node in the sidebar). Also covers normal
|
||||
// ancestor-chain resolution, the not-found case, and nested children.
|
||||
|
||||
function node(id: string, over: 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: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("findBreadcrumbPath", () => {
|
||||
it("does NOT mutate the input tree when a node has an empty/whitespace name", () => {
|
||||
// A whitespace-only-named node nested under a blank-named root.
|
||||
const target = node("target", { name: " " });
|
||||
const root = node("root", { name: "", hasChildren: true, children: [target] });
|
||||
const tree = [root];
|
||||
|
||||
const result = findBreadcrumbPath(tree, "target");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// The RETURNED chain shows "Untitled" for both blank nodes.
|
||||
expect(result!.map((n) => n.name)).toEqual(["Untitled", "Untitled"]);
|
||||
// The original input nodes are untouched (still blank).
|
||||
expect(root.name).toBe("");
|
||||
expect(target.name).toBe(" ");
|
||||
// The renamed breadcrumb entries are fresh copies, not the input objects.
|
||||
expect(result![0]).not.toBe(root);
|
||||
expect(result![1]).not.toBe(target);
|
||||
});
|
||||
|
||||
it("returns the SAME node reference (no copy) when the name is non-empty", () => {
|
||||
// No rename needed -> the node is passed through by reference (cheap path).
|
||||
const target = node("target", { name: "Real Title" });
|
||||
const result = findBreadcrumbPath([target], "target");
|
||||
expect(result![0]).toBe(target);
|
||||
expect(result![0].name).toBe("Real Title");
|
||||
});
|
||||
|
||||
it("resolves the full ancestor chain ending at the target", () => {
|
||||
const target = node("c");
|
||||
const mid = node("b", { hasChildren: true, children: [target] });
|
||||
const root = node("a", { hasChildren: true, children: [mid] });
|
||||
const result = findBreadcrumbPath([root], "c");
|
||||
expect(result!.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("finds a target nested under a deeper sibling branch", () => {
|
||||
// Two root branches; the target lives inside the second branch's child.
|
||||
const target = node("deep");
|
||||
const branch2 = node("r2", {
|
||||
hasChildren: true,
|
||||
children: [node("x"), node("y", { hasChildren: true, children: [target] })],
|
||||
});
|
||||
const branch1 = node("r1", { hasChildren: true, children: [node("z")] });
|
||||
const result = findBreadcrumbPath([branch1, branch2], "deep");
|
||||
expect(result!.map((n) => n.id)).toEqual(["r2", "y", "deep"]);
|
||||
});
|
||||
|
||||
it("returns null when the page id is not present in the tree", () => {
|
||||
const root = node("root", { hasChildren: true, children: [node("child")] });
|
||||
expect(findBreadcrumbPath([root], "missing")).toBeNull();
|
||||
expect(findBreadcrumbPath([], "anything")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
closeIds,
|
||||
mergeRootTrees,
|
||||
loadedOpenBranchIds,
|
||||
sortPositionKeys,
|
||||
pageToTreeNode,
|
||||
} from "./utils";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -60,6 +62,82 @@ function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode {
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortPositionKeys", () => {
|
||||
it("orders items ascending by their fractional `position` string", () => {
|
||||
const items = [
|
||||
{ id: "c", position: "a5" },
|
||||
{ id: "a", position: "a1" },
|
||||
{ id: "b", position: "a3" },
|
||||
];
|
||||
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("is a stable sort: equal positions keep their input order", () => {
|
||||
const items = [
|
||||
{ id: "x", position: "a1" },
|
||||
{ id: "y", position: "a1" },
|
||||
{ id: "z", position: "a1" },
|
||||
];
|
||||
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["x", "y", "z"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageToTreeNode", () => {
|
||||
function pageRow(over: Partial<IPage> = {}): IPage {
|
||||
return {
|
||||
id: "p1",
|
||||
slugId: "slug-p1",
|
||||
title: "My Page",
|
||||
icon: "📄",
|
||||
position: "a1",
|
||||
hasChildren: true,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
...over,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
it("maps page.title -> node.name and copies the core fields", () => {
|
||||
const node = pageToTreeNode(pageRow());
|
||||
// The non-trivial transform: a page's `title` becomes the tree node's `name`.
|
||||
expect(node.name).toBe("My Page");
|
||||
expect(node.id).toBe("p1");
|
||||
expect(node.slugId).toBe("slug-p1");
|
||||
expect(node.icon).toBe("📄");
|
||||
expect(node.position).toBe("a1");
|
||||
expect(node.spaceId).toBe("space-1");
|
||||
expect(node.hasChildren).toBe(true);
|
||||
// Always materialized with an empty children array.
|
||||
expect(node.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives canEdit from page.permissions.canEdit when the flat field is absent", () => {
|
||||
const node = pageToTreeNode(
|
||||
pageRow({ canEdit: undefined, permissions: { canEdit: true } } as Partial<IPage>),
|
||||
);
|
||||
expect(node.canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers the flat page.canEdit over permissions.canEdit", () => {
|
||||
const node = pageToTreeNode(
|
||||
pageRow({ canEdit: false, permissions: { canEdit: true } } as Partial<IPage>),
|
||||
);
|
||||
expect(node.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it("carries temporaryExpiresAt straight off the page", () => {
|
||||
const expiresAt = "2026-06-27T21:00:00.000Z";
|
||||
expect(pageToTreeNode(pageRow({ temporaryExpiresAt: expiresAt })).temporaryExpiresAt).toBe(
|
||||
expiresAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies overrides on top of the mapped fields (e.g. optimistic blank name)", () => {
|
||||
const node = pageToTreeNode(pageRow(), { name: "" });
|
||||
expect(node.name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("builds one node per unique page", () => {
|
||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||
|
||||
@@ -70,18 +70,22 @@ export function findBreadcrumbPath(
|
||||
path: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "Untitled";
|
||||
}
|
||||
// Never mutate the input tree (it is the live, shared sidebar tree state).
|
||||
// When a node has no usable name, surface "Untitled" via a shallow copy that
|
||||
// only the returned breadcrumb chain sees — the source node stays untouched.
|
||||
const displayNode: SpaceTreeNode =
|
||||
!node.name || node.name.trim() === ""
|
||||
? { ...node, name: "Untitled" }
|
||||
: node;
|
||||
|
||||
if (node.id === pageId) {
|
||||
return [...path, node];
|
||||
return [...path, displayNode];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const newPath = findBreadcrumbPath(node.children, pageId, [
|
||||
...path,
|
||||
node,
|
||||
displayNode,
|
||||
]);
|
||||
if (newPath) {
|
||||
return newPath;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
applyUpdateOne,
|
||||
} from "./tree-socket-reducers";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -338,3 +339,76 @@ describe("applyAddTreeNode", () => {
|
||||
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyUpdateOne", () => {
|
||||
// A loaded two-level tree so we can patch both a root and a nested node.
|
||||
const buildTree = (): SpaceTreeNode[] => [
|
||||
node("root", {
|
||||
position: "a0",
|
||||
name: "Root",
|
||||
icon: "📁",
|
||||
hasChildren: true,
|
||||
children: [node("child", { position: "a1", parentPageId: "root", name: "Child", icon: "📄" })],
|
||||
}),
|
||||
];
|
||||
|
||||
// Build the UpdateEvent envelope; only `id`/`payload` matter to the reducer.
|
||||
const ev = (id: string, payload: Record<string, unknown>) =>
|
||||
({
|
||||
operation: "updateOne",
|
||||
spaceId: "space-1",
|
||||
entity: ["pages"],
|
||||
id,
|
||||
payload,
|
||||
}) as unknown as Parameters<typeof applyUpdateOne>[1];
|
||||
|
||||
it("applies a title-only update to the node's name (icon untouched)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", { title: "Renamed" }));
|
||||
const child = treeModel.find(next, "child");
|
||||
expect(child?.name).toBe("Renamed");
|
||||
// Icon is left as it was.
|
||||
expect(child?.icon).toBe("📄");
|
||||
});
|
||||
|
||||
it("applies an icon-only update to the node's icon (name untouched)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("root", { icon: "🔥" }));
|
||||
const root = treeModel.find(next, "root");
|
||||
expect(root?.icon).toBe("🔥");
|
||||
expect(root?.name).toBe("Root");
|
||||
});
|
||||
|
||||
it("applies a combined title + icon update", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", { title: "Both", icon: "⭐" }));
|
||||
const child = treeModel.find(next, "child");
|
||||
expect(child?.name).toBe("Both");
|
||||
expect(child?.icon).toBe("⭐");
|
||||
});
|
||||
|
||||
it("returns prev UNCHANGED (same reference) when the id is not loaded", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("ghost", { title: "Nope" }));
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("returns prev UNCHANGED (same reference) for a no-op payload (no title/icon)", () => {
|
||||
// The node exists, but the payload carries neither title nor icon -> nothing
|
||||
// to patch, so the reducer must hand back the same array reference.
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", {}));
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("treats an explicit null icon/title as a value to apply (undefined check, not truthiness)", () => {
|
||||
// The reducer guards on `!== undefined`, so a clearing null IS applied.
|
||||
const tree = buildTree();
|
||||
const next = applyUpdateOne(tree, ev("child", { title: "", icon: null }));
|
||||
const child = treeModel.find(next, "child");
|
||||
expect(child?.name).toBe("");
|
||||
expect(child?.icon).toBeNull();
|
||||
// And it did change something -> a fresh reference, not prev.
|
||||
expect(next).not.toBe(tree);
|
||||
});
|
||||
});
|
||||
|
||||
+152
@@ -3,6 +3,9 @@ import {
|
||||
resolveCardStatus,
|
||||
isEndpointConfigured,
|
||||
resolveKeyField,
|
||||
nextReindexPollInterval,
|
||||
isReindexComplete,
|
||||
isReindexButtonLoading,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
@@ -71,3 +74,152 @@ describe('resolveKeyField (write-only key payload)', () => {
|
||||
expect(resolveKeyField('', false)).toEqual({ set: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextReindexPollInterval', () => {
|
||||
const INTERVAL = 5000;
|
||||
const base = { now: 1_000, intervalMs: INTERVAL };
|
||||
|
||||
it('does not poll when no reindex deadline is set', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
deadline: null,
|
||||
status: { reindexing: true, indexedPages: 0, totalPages: 478 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps polling while the server reports an active run', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: true, indexedPages: 120, totalPages: 478 },
|
||||
}),
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('keeps polling during an active run even if counts momentarily look full', () => {
|
||||
// The run clears its progress record only at the very end, so a transient
|
||||
// indexed==total while reindexing is still true must NOT stop polling.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: true, indexedPages: 478, totalPages: 478 },
|
||||
}),
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('stops once the run is finished AND fully indexed', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps polling within the deadline when not yet done and no active flag', () => {
|
||||
// First poll right after enqueue, before the worker publishes progress.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 0, totalPages: 478 },
|
||||
}),
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('cap always wins: stops once past the deadline even if still reindexing', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
deadline: 1_000,
|
||||
now: 2_000, // past the deadline
|
||||
intervalMs: INTERVAL,
|
||||
status: { reindexing: true, indexedPages: 200, totalPages: 478 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('stops on an empty workspace (0 of 0) once the run is finished', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 0, totalPages: 0 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReindexComplete', () => {
|
||||
it('false when no status yet', () => {
|
||||
expect(isReindexComplete(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('false while a run is still active (even at indexed==total)', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('false when finished but not yet fully indexed', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('true once finished and fully indexed', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReindexButtonLoading', () => {
|
||||
it('loads while the POST mutation is pending', () => {
|
||||
expect(
|
||||
isReindexButtonLoading({
|
||||
mutationPending: true,
|
||||
deadline: null,
|
||||
status: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT load post-cap: deadline nulled but reindexing left stale-true', () => {
|
||||
// The key case: after the poll cap fires `reindexDeadline` is null while
|
||||
// `settings.reindexing` can be a stale `true` from the last poll. Gating on
|
||||
// the deadline keeps the spinner from sticking forever so the admin can
|
||||
// restart.
|
||||
expect(
|
||||
isReindexButtonLoading({
|
||||
mutationPending: false,
|
||||
deadline: null,
|
||||
status: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('loads during an active run within the poll window', () => {
|
||||
expect(
|
||||
isReindexButtonLoading({
|
||||
mutationPending: false,
|
||||
deadline: 10_000,
|
||||
status: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not load once the run finished while still polling', () => {
|
||||
expect(
|
||||
isReindexButtonLoading({
|
||||
mutationPending: false,
|
||||
deadline: 10_000,
|
||||
status: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+99
-18
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/features/workspace/queries/ai-settings-query.ts";
|
||||
import {
|
||||
AiTestCapability,
|
||||
IAiSettings,
|
||||
IAiSettingsUpdate,
|
||||
SttApiStyle,
|
||||
ChatApiStyle,
|
||||
@@ -169,6 +170,73 @@ export function resolveKeyField(
|
||||
return { set: false };
|
||||
}
|
||||
|
||||
// Subset of the status payload that drives the reindex poll decisions.
|
||||
type ReindexStatus = Pick<
|
||||
IAiSettings,
|
||||
"reindexing" | "indexedPages" | "totalPages"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
||||
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
||||
*
|
||||
* Polls while the server reports an ACTIVE run (`reindexing === true`) OR we are
|
||||
* still within the deadline window and not yet fully indexed. Stops once the run
|
||||
* has finished AND everything is indexed (server cleared its progress record and
|
||||
* fell back to the DB coverage count), or the deadline cap is hit — the cap
|
||||
* always wins so a stuck/never-clearing progress record can't poll forever.
|
||||
*/
|
||||
export function nextReindexPollInterval(args: {
|
||||
deadline: number | null;
|
||||
now: number;
|
||||
intervalMs: number;
|
||||
status?: ReindexStatus;
|
||||
}): number | false {
|
||||
const { deadline, now, intervalMs, status } = args;
|
||||
if (deadline === null) return false;
|
||||
// Cap always wins.
|
||||
if (now > deadline) return false;
|
||||
// Active run → keep polling even if the momentary counts already look full.
|
||||
if (status?.reindexing) return intervalMs;
|
||||
// Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse
|
||||
// isReindexComplete so the completeness check lives in exactly one place.
|
||||
if (isReindexComplete(status)) return false;
|
||||
// Within the deadline and not yet done → keep polling.
|
||||
return intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the reindex poll deadline should be cleared: the server reports no
|
||||
* active run AND the count is complete. The single source of truth for the
|
||||
* "reindex finished" check — `nextReindexPollInterval` reuses it for its stop
|
||||
* condition (sans the cap, which the effect handles via time).
|
||||
*/
|
||||
export function isReindexComplete(status?: ReindexStatus): boolean {
|
||||
return (
|
||||
!!status && !status.reindexing && status.indexedPages >= status.totalPages
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the reindex button should show its spinner (and stay disabled).
|
||||
*
|
||||
* Spins while the POST is in flight, and for the WHOLE background run while the
|
||||
* server reports `reindexing === true`. The `deadline !== null` gate is the
|
||||
* load-bearing part: once the 120s poll cap fires it nulls `reindexDeadline`
|
||||
* and stops refetching, so `status` (settings?.reindexing) can be a stale
|
||||
* `true` from the last poll. Without the gate the spinner would stick forever
|
||||
* for a run that outlives the cap and block a restart; gating on the active
|
||||
* poll window clears it so the admin can re-trigger.
|
||||
*/
|
||||
export function isReindexButtonLoading(args: {
|
||||
mutationPending: boolean;
|
||||
deadline: number | null;
|
||||
status?: boolean;
|
||||
}): boolean {
|
||||
const { mutationPending, deadline, status } = args;
|
||||
return mutationPending || (deadline !== null && status === true);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -215,31 +283,34 @@ export default function AiProviderSettings() {
|
||||
// PRE-job counts immediately, so the only way the "Indexed X of Y" counter
|
||||
// visibly climbs is to keep polling the settings query while the job runs.
|
||||
// `reindexDeadline` is the timestamp until which we poll (set on reindex
|
||||
// success); polling stops early once indexed === total. Bounded so a stuck
|
||||
// job can never poll forever.
|
||||
const REINDEX_POLL_INTERVAL = 3000; // ms between refetches while indexing
|
||||
// success). Polling tracks the server's `reindexing` flag: it keeps going for
|
||||
// the whole active run and stops promptly once the server reports the run is
|
||||
// finished. Bounded by the cap so a stuck/never-clearing progress record can
|
||||
// never poll forever.
|
||||
const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing
|
||||
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
|
||||
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
|
||||
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => {
|
||||
if (reindexDeadline === null) return false;
|
||||
// Past the cap → stop polling (cleared via the effect below too).
|
||||
if (Date.now() > reindexDeadline) return false;
|
||||
const data = query.state.data;
|
||||
// Stop once everything is indexed; otherwise keep polling.
|
||||
if (data && data.indexedPages >= data.totalPages) return false;
|
||||
return REINDEX_POLL_INTERVAL;
|
||||
});
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||
nextReindexPollInterval({
|
||||
deadline: reindexDeadline,
|
||||
now: Date.now(),
|
||||
intervalMs: REINDEX_POLL_INTERVAL,
|
||||
status: query.state.data,
|
||||
}),
|
||||
);
|
||||
|
||||
// Stop polling once the work is done or the cap is reached. Also clears on
|
||||
// Stop polling once the run is finished or the cap is reached. Also clears on
|
||||
// unmount because the deadline state goes away with the component.
|
||||
useEffect(() => {
|
||||
if (reindexDeadline === null) return;
|
||||
// "Done" matches the refetchInterval stop condition (indexed >= total),
|
||||
// including an empty workspace (0 >= 0), so the deadline clears promptly
|
||||
// instead of waiting out the cap.
|
||||
if (settings && settings.indexedPages >= settings.totalPages) {
|
||||
// "Done" matches the refetchInterval stop condition: the server reports no
|
||||
// active run AND the count is complete (indexed >= total, incl. an empty
|
||||
// workspace 0 >= 0), so the deadline clears promptly instead of waiting out
|
||||
// the cap. While `reindexing` is still true we keep the deadline so polling
|
||||
// continues for the whole run.
|
||||
if (isReindexComplete(settings)) {
|
||||
setReindexDeadline(null);
|
||||
return;
|
||||
}
|
||||
@@ -1031,7 +1102,17 @@ export default function AiProviderSettings() {
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
loading={reindexMutation.isPending}
|
||||
// Spin for the WHOLE run: the POST resolves immediately, but the
|
||||
// background job keeps running, so also stay loading while the
|
||||
// server reports `reindexing` (this also blocks a redundant
|
||||
// re-trigger mid-run; the server de-dupes regardless). The
|
||||
// deadline gate (and why it matters post-cap) lives in
|
||||
// `isReindexButtonLoading`, which is unit-tested.
|
||||
loading={isReindexButtonLoading({
|
||||
mutationPending: reindexMutation.isPending,
|
||||
deadline: reindexDeadline,
|
||||
status: settings?.reindexing,
|
||||
})}
|
||||
onClick={() =>
|
||||
reindexMutation.mutate(undefined, {
|
||||
// Begin bounded polling so the counter climbs as the async
|
||||
|
||||
@@ -23,8 +23,12 @@ export function useAiSettingsQuery(
|
||||
enabled: boolean = true,
|
||||
// While reindexing runs as an async background job, the counter only climbs
|
||||
// if the client keeps refetching. The component passes a refetchInterval
|
||||
// function that polls until indexed === total or a bounded deadline, then
|
||||
// returns false to stop. See AiProviderSettings.
|
||||
// function (`nextReindexPollInterval`) that keeps polling while the server
|
||||
// reports an active run (reindexing === true) OR we are still within the
|
||||
// bounded deadline and not yet fully indexed; it returns false to stop only
|
||||
// once the run has finished AND indexed >= total, or the deadline cap is hit
|
||||
// (the cap always wins). Note: a transient indexed === total during an active
|
||||
// run does NOT stop polling. See AiProviderSettings.
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface IAiSettings {
|
||||
// RAG indexing coverage (pages indexed for semantic search).
|
||||
indexedPages: number;
|
||||
totalPages: number;
|
||||
// True while a full workspace reindex is actively running; the counts above
|
||||
// then reflect the live run progress (done climbs 0 -> total).
|
||||
reindexing?: boolean;
|
||||
}
|
||||
|
||||
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
||||
|
||||
Reference in New Issue
Block a user