e10e79c0c4
PR-1 'core' of #370: introduces page_history.kind ('manual'|'agent'|'idle'| 'boundary'; legacy null = autosave) and rebuilds the snapshot triggers around a three-tier intentionality model. Draft durability (pages/ydoc hocuspocus autosave) is unchanged; only the frequency and labelling of history points change. - Migration 20260705T120000: page_history.kind nullable varchar(20), no default. - Manual Save: one stateless 'save-version' path for human AND agent; kind is derived SERVER-SIDE from the signed context.actor (never the payload), readOnly connections rejected, the fresh ydoc runs through the existing store path (no REST race), then broadcasts version.saved. - Idle-flush: trailing debounce (one BullMQ job per page, remove-then-readd) with IDLE_INTERVAL_USER=60m / AGENT=15m AND a max-wait ceiling (IDLE_MAX_WAIT_USER=10m / AGENT=5m) so a continuous editing session can't starve the autosnapshot (review round-1 WARNING). - Boundary: generalized from the user→agent special-case to ANY lastUpdatedSource transition (user↔agent↔git), same isDeepStrictEqual gate — covers git-sync free. - Removed the agent delay=0 fast path and the old HISTORY_FAST_* constants; the agent joins the common idle pipeline. - Promote-not-dup: a manual save on unchanged content promotes the latest autosave's kind in place (or no-ops if already manual) instead of duplicating a heavy content row. - Client: mod+S hotkey + menu button (hidden when readOnly), history-panel kind badges, dimmed autosaves, a 'versions only' filter (indices map to the full list so diff/restore still target the true previous snapshot), live refresh on version.saved. Internal review: APPROVE-with-suggestions; the round-1 WARNING (idle starvation) is fixed here via the max-wait ceiling, and the generalized-boundary + ceiling behaviours are pinned with new tests (115 collab/repo specs green, server tsc 0). Deferred to later PRs: shares.published_mode (PR-2), the save_page_version MCP tool + role prompts (PR-3), actor='git' wiring into #359 (PR-4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
510 lines
15 KiB
TypeScript
510 lines
15 KiB
TypeScript
import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
|
import {
|
|
IconArrowRight,
|
|
IconArrowsHorizontal,
|
|
IconClockHour4,
|
|
IconDeviceFloppy,
|
|
IconDots,
|
|
IconEye,
|
|
IconEyeOff,
|
|
IconFileExport,
|
|
IconHistory,
|
|
IconLink,
|
|
IconList,
|
|
IconMarkdown,
|
|
IconPrinter,
|
|
IconStar,
|
|
IconStarFilled,
|
|
IconTrash,
|
|
IconWifiOff,
|
|
} from "@tabler/icons-react";
|
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
|
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
|
import { useClipboard } from "@/hooks/use-clipboard";
|
|
import { useParams } from "react-router-dom";
|
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|
import {
|
|
useToggleTemporaryMutation,
|
|
syncTemporaryExpiresInCache,
|
|
} from "@/features/page-embed/queries/page-embed-query.ts";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { notifications } from "@mantine/notifications";
|
|
import { getAppUrl } from "@/lib/config.ts";
|
|
import { extractPageSlugId } from "@/lib";
|
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import ExportModal from "@/components/common/export-modal";
|
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
|
import {
|
|
collabProviderAtom,
|
|
pageEditorAtom,
|
|
yjsConnectionStatusAtom,
|
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
import {
|
|
SAVE_VERSION_MESSAGE_TYPE,
|
|
saveVersionPending,
|
|
} from "@/features/page-history/version-messages.ts";
|
|
import { formattedDate } from "@/lib/time.ts";
|
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
|
import {
|
|
useFavoriteIds,
|
|
useAddFavoriteMutation,
|
|
useRemoveFavoriteMutation,
|
|
} from "@/features/favorite/queries/favorite-query";
|
|
import {
|
|
useWatchStatusQuery,
|
|
useWatchPageMutation,
|
|
useUnwatchPageMutation,
|
|
} from "@/features/page/queries/watcher-query";
|
|
import ShareModal from "@/features/share/components/share-modal.tsx";
|
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
|
|
interface PageHeaderMenuProps {
|
|
readOnly?: boolean;
|
|
}
|
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|
const { t } = useTranslation();
|
|
const commentsTriggerProps = useAsideTriggerProps("comments");
|
|
const tocTriggerProps = useAsideTriggerProps("toc");
|
|
const { pageSlug } = useParams();
|
|
const { data: page } = usePageQuery({
|
|
pageId: extractPageSlugId(pageSlug),
|
|
});
|
|
const isDeleted = !!page?.deletedAt;
|
|
const [workspace] = useAtom(workspaceAtom);
|
|
const collabProvider = useAtomValue(collabProviderAtom);
|
|
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
|
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
|
|
|
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
|
|
// human; the server derives the tier from the signed actor. Readers can't save
|
|
// (the button is hidden and the collab connection is read-only server-side).
|
|
const handleSaveVersion = useCallback(() => {
|
|
if (readOnly || !collabProvider) return;
|
|
// Flag this client as the initiator so only it shows the confirmation toast;
|
|
// a safety timeout clears it if no broadcast comes back (e.g. offline).
|
|
saveVersionPending.current = true;
|
|
window.setTimeout(() => {
|
|
saveVersionPending.current = false;
|
|
}, 5000);
|
|
collabProvider.sendStateless(
|
|
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
|
);
|
|
}, [readOnly, collabProvider]);
|
|
|
|
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
|
|
// Editable` + empty ignore-list so it fires while typing in the editor/title.
|
|
useHotkeys(
|
|
[["mod+S", handleSaveVersion, { preventDefault: true }]],
|
|
[],
|
|
true,
|
|
);
|
|
|
|
useHotkeys(
|
|
[
|
|
[
|
|
"mod+F",
|
|
() => {
|
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
document.dispatchEvent(event);
|
|
},
|
|
],
|
|
[
|
|
"Escape",
|
|
() => {
|
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
document.dispatchEvent(event);
|
|
},
|
|
{ preventDefault: false },
|
|
],
|
|
],
|
|
[],
|
|
);
|
|
|
|
if (isDeleted) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ConnectionWarning />
|
|
|
|
{!readOnly && <PageEditModeToggle size="xs" />}
|
|
|
|
{/* Hide the Share entry point for readers; the toggle inside is inert
|
|
without edit permission, so gate it like other edit-only actions
|
|
(issue #133) */}
|
|
{!readOnly && !workspaceSharingDisabled && (
|
|
<ShareModal readOnly={false} />
|
|
)}
|
|
|
|
<Button
|
|
variant="subtle"
|
|
color="dark"
|
|
size="compact-sm"
|
|
{...commentsTriggerProps}
|
|
>
|
|
{t("Comments")}
|
|
</Button>
|
|
|
|
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="dark"
|
|
aria-label={t("Table of contents")}
|
|
{...tocTriggerProps}
|
|
>
|
|
<IconList size={20} stroke={2} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
|
|
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface PageActionMenuProps {
|
|
readOnly?: boolean;
|
|
onSaveVersion?: () => void;
|
|
}
|
|
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
|
const { t } = useTranslation();
|
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
|
const clipboard = useClipboard({ timeout: 500 });
|
|
const { pageSlug, spaceSlug } = useParams();
|
|
const { data: page, isLoading } = usePageQuery({
|
|
pageId: extractPageSlugId(pageSlug),
|
|
});
|
|
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
|
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
|
useDisclosure(false);
|
|
const [
|
|
movePageModalOpened,
|
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
|
] = useDisclosure(false);
|
|
const [pageEditor] = useAtom(pageEditorAtom);
|
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
|
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
|
const addFavoriteMutation = useAddFavoriteMutation();
|
|
const removeFavoriteMutation = useRemoveFavoriteMutation();
|
|
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
|
|
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
|
const watchPage = useWatchPageMutation();
|
|
const unwatchPage = useUnwatchPageMutation();
|
|
const toggleTemporary = useToggleTemporaryMutation();
|
|
const isTemporary = !!page?.temporaryExpiresAt;
|
|
|
|
const handleToggleTemporary = async () => {
|
|
if (!page?.id) return;
|
|
const next = !isTemporary;
|
|
try {
|
|
const res = await toggleTemporary.mutateAsync({
|
|
pageId: page.id,
|
|
temporary: next,
|
|
});
|
|
// Reflect the new deadline in the page cache (menu label + banner) AND in
|
|
// the sidebar tree node so its clock marker updates immediately, no reload.
|
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
|
notifications.show({
|
|
message: next
|
|
? t("Note will move to trash unless made permanent")
|
|
: t("Note is now permanent"),
|
|
});
|
|
} catch {
|
|
// mutation surfaces the error via notifications
|
|
}
|
|
};
|
|
|
|
const handleCopyLink = () => {
|
|
const pageUrl =
|
|
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
|
|
|
|
clipboard.copy(pageUrl);
|
|
notifications.show({ message: t("Link copied") });
|
|
};
|
|
|
|
const handleCopyAsMarkdown = () => {
|
|
if (!pageEditor) return;
|
|
const html = pageEditor.getHTML();
|
|
const markdown = htmlToMarkdown(html);
|
|
const title = page?.title ? `# ${page.title}\n\n` : "";
|
|
clipboard.copy(`${title}${markdown}`);
|
|
notifications.show({ message: t("Copied") });
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
setTimeout(() => {
|
|
window.print();
|
|
}, 250);
|
|
};
|
|
|
|
const openHistoryModal = () => {
|
|
setHistoryModalOpen(true);
|
|
};
|
|
|
|
const handleDeletePage = () => {
|
|
handleDelete(page.id);
|
|
};
|
|
|
|
const handleToggleFavorite = () => {
|
|
if (!page?.id) return;
|
|
const params = { type: "page" as const, pageId: page.id };
|
|
if (isFavorited) {
|
|
removeFavoriteMutation.mutate(params);
|
|
} else {
|
|
addFavoriteMutation.mutate(params);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Menu
|
|
shadow="xl"
|
|
position="bottom-end"
|
|
offset={20}
|
|
width={230}
|
|
withArrow
|
|
arrowPosition="center"
|
|
>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="dark"
|
|
aria-label={t("Page actions")}
|
|
>
|
|
<IconDots size={20} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<IconLink size={16} />}
|
|
onClick={handleCopyLink}
|
|
>
|
|
{t("Copy link")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconMarkdown size={16} />}
|
|
onClick={handleCopyAsMarkdown}
|
|
>
|
|
{t("Copy as Markdown")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={
|
|
isFavorited ? (
|
|
<IconStarFilled size={16} color="var(--mantine-color-yellow-5)" />
|
|
) : (
|
|
<IconStar size={16} />
|
|
)
|
|
}
|
|
onClick={handleToggleFavorite}
|
|
>
|
|
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
|
</Menu.Item>
|
|
|
|
{watchStatus?.watching ? (
|
|
<Menu.Item
|
|
leftSection={<IconEyeOff size={16} />}
|
|
onClick={() => unwatchPage.mutate(page.id)}
|
|
>
|
|
{t("Stop watching")}
|
|
</Menu.Item>
|
|
) : (
|
|
<Menu.Item
|
|
leftSection={<IconEye size={16} />}
|
|
onClick={() => watchPage.mutate(page.id)}
|
|
>
|
|
{t("Watch page")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
<Menu.Divider />
|
|
|
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
|
<Group wrap="nowrap">
|
|
<PageWidthToggle label={t("Full width")} />
|
|
</Group>
|
|
</Menu.Item>
|
|
|
|
{!readOnly && (
|
|
<Menu.Item
|
|
leftSection={<IconDeviceFloppy size={16} />}
|
|
onClick={onSaveVersion}
|
|
rightSection={
|
|
<Text size="xs" c="dimmed">
|
|
{t("Ctrl+S")}
|
|
</Text>
|
|
}
|
|
>
|
|
{t("Save version")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
<Menu.Item
|
|
leftSection={<IconHistory size={16} />}
|
|
onClick={openHistoryModal}
|
|
>
|
|
{t("Page history")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Divider />
|
|
|
|
{!readOnly && (
|
|
<Menu.Item
|
|
leftSection={<IconArrowRight size={16} />}
|
|
onClick={openMovePageModal}
|
|
>
|
|
{t("Move to space")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
<Menu.Item
|
|
leftSection={<IconFileExport size={16} />}
|
|
onClick={openExportModal}
|
|
>
|
|
{t("Export")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconPrinter size={16} />}
|
|
onClick={handlePrint}
|
|
>
|
|
{t("Print PDF")}
|
|
</Menu.Item>
|
|
|
|
{!readOnly && (
|
|
<>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<IconClockHour4 size={16} />}
|
|
onClick={handleToggleTemporary}
|
|
>
|
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
color={"red"}
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={handleDeletePage}
|
|
>
|
|
{t("Move to trash")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
|
|
<Menu.Divider />
|
|
|
|
<>
|
|
<Group px="sm" wrap="nowrap" style={{ cursor: "pointer" }}>
|
|
<Tooltip
|
|
label={t("Edited by {{name}} {{time}}", {
|
|
name: page.lastUpdatedBy.name,
|
|
time: pageUpdatedAt,
|
|
})}
|
|
position="left-start"
|
|
>
|
|
<div style={{ width: 210 }}>
|
|
<Text size="xs" c="dimmed" truncate="end">
|
|
{t("Word count: {{wordCount}}", {
|
|
wordCount: pageEditor?.storage?.characterCount?.words(),
|
|
})}
|
|
</Text>
|
|
|
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
<Trans
|
|
defaults="Created by: <b>{{creatorName}}</b>"
|
|
values={{ creatorName: page?.creator?.name }}
|
|
components={{ b: <Text span fw={500} /> }}
|
|
/>
|
|
</Text>
|
|
<Text size="xs" c="dimmed" truncate="end">
|
|
{t("Created at: {{time}}", {
|
|
time: formattedDate(page.createdAt),
|
|
})}
|
|
</Text>
|
|
</div>
|
|
</Tooltip>
|
|
</Group>
|
|
</>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
|
|
<ExportModal
|
|
type="page"
|
|
id={page.id}
|
|
open={exportOpened}
|
|
onClose={closeExportModal}
|
|
/>
|
|
|
|
<MovePageModal
|
|
pageId={page.id}
|
|
slugId={page.slugId}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeMoveSpaceModal}
|
|
open={movePageModalOpened}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ConnectionWarning() {
|
|
const { t } = useTranslation();
|
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
|
const [showWarning, setShowWarning] = useState(false);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const isDisconnected = ["disconnected", "connecting"].includes(
|
|
yjsConnectionStatus,
|
|
);
|
|
|
|
if (isDisconnected) {
|
|
if (!timeoutRef.current) {
|
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
|
}
|
|
} else {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
setShowWarning(false);
|
|
}
|
|
}, [yjsConnectionStatus]);
|
|
|
|
// Cleanup only on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
if (!showWarning) return null;
|
|
|
|
return (
|
|
<Tooltip
|
|
label={t("Real-time editor connection lost. Retrying...")}
|
|
openDelay={250}
|
|
withArrow
|
|
>
|
|
<ThemeIcon
|
|
variant="default"
|
|
c="red"
|
|
role="status"
|
|
aria-label={t("Real-time editor connection lost. Retrying...")}
|
|
style={{ border: "none" }}
|
|
>
|
|
<IconWifiOff size={20} stroke={2} />
|
|
</ThemeIcon>
|
|
</Tooltip>
|
|
);
|
|
}
|