canopycms 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +187 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1060 -172
- package/dist/cli/generate-ai-content.js +732 -101
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.js +23 -16
- package/dist/context.js +8 -7
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +40 -25
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- package/package.json +2 -1
|
@@ -10,8 +10,8 @@ import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
|
10
10
|
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
11
11
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
12
12
|
/**
|
|
13
|
-
* The branch's name. Its menu, headed by the
|
|
14
|
-
* name truncates shorter, so the actions don't clip.
|
|
13
|
+
* The branch's name. Its menu, headed by the name untruncated, holds the branch actions. Below
|
|
14
|
+
* `sm` the name truncates shorter, so the actions don't clip.
|
|
15
15
|
*/
|
|
16
16
|
export function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitchBranch, onRefreshBranch, onDiscardAll, pillRef, }) {
|
|
17
17
|
const trigger = useRef(null);
|
|
@@ -3,6 +3,8 @@ import type { BranchStatus, PullRequestState } from '../../types.js';
|
|
|
3
3
|
import type { SaveAllProgress } from '../hooks/save-all.js';
|
|
4
4
|
export interface EditorHeaderProps {
|
|
5
5
|
branchName: string;
|
|
6
|
+
/** What the pill shows for the branch, when it is not `branchName`. */
|
|
7
|
+
branchLabel?: string;
|
|
6
8
|
/** Whether operations are currently in progress. */
|
|
7
9
|
busy: boolean;
|
|
8
10
|
/** Entries with unsaved changes on the branch. */
|
|
@@ -72,4 +74,4 @@ export interface EditorHeaderProps {
|
|
|
72
74
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
73
75
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
74
76
|
*/
|
|
75
|
-
export declare function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, onApprove, onRequestChanges, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
77
|
+
export declare function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, onApprove, onRequestChanges, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -19,7 +19,7 @@ import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.j
|
|
|
19
19
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
20
20
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
21
21
|
*/
|
|
22
|
-
export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
|
|
22
|
+
export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
|
|
23
23
|
const reviewRole = reviewRoleOf({
|
|
24
24
|
status: branchStatus,
|
|
25
25
|
createdBy: branchCreatedBy,
|
|
@@ -81,7 +81,7 @@ export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, b
|
|
|
81
81
|
returnFocusTo: () => focusTarget(2),
|
|
82
82
|
};
|
|
83
83
|
const caretOnWay = narrow && !!barAction && !!workflow;
|
|
84
|
-
return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
|
|
84
|
+
return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
|
|
85
85
|
// Its reserved width follows this branch's counts, not the session's largest.
|
|
86
86
|
, { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
|
|
87
87
|
(!branchName
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Changes to <title>" drawer: one card per changed field of an entry, its value at the
|
|
3
|
+
* base beside its saved value on the branch. LazyEntryChangesDrawer loads it, so the diff model
|
|
4
|
+
* and jsdiff stay out of the editor's first chunk.
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import type { BranchChangeEntry } from '../../api/branch-changes.js';
|
|
8
|
+
import type { FieldConfig } from '../../config/index.js';
|
|
9
|
+
import type { EntryChangeState } from '../hooks/useBranchChangesData.js';
|
|
10
|
+
/** The entry a drawer compares, as the editor knows it. */
|
|
11
|
+
export interface EntryChangesSubject {
|
|
12
|
+
logicalPath: string;
|
|
13
|
+
/** A moved entry's path at the base. */
|
|
14
|
+
from?: string;
|
|
15
|
+
/** The entry's content id, which both sides must carry. */
|
|
16
|
+
contentId?: string;
|
|
17
|
+
status: BranchChangeEntry['status'];
|
|
18
|
+
title: string;
|
|
19
|
+
/** The entry type's fields; empty when the editor has none for it. */
|
|
20
|
+
fields: readonly FieldConfig[];
|
|
21
|
+
/** A moved entry's path at the base, in human labels. */
|
|
22
|
+
movedFromLabel?: string;
|
|
23
|
+
/** The open entry has edits not yet saved, which the comparison leaves out. */
|
|
24
|
+
unsavedEdits?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface EntryChangesDrawerProps {
|
|
27
|
+
opened: boolean;
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
/** The close transition has finished. */
|
|
30
|
+
onExited?: () => void;
|
|
31
|
+
/** Names the element that takes focus after the drawer closes. */
|
|
32
|
+
returnFocusTo?: () => HTMLElement | null;
|
|
33
|
+
/** The base branch's name, for the copy. */
|
|
34
|
+
base: string;
|
|
35
|
+
subject: EntryChangesSubject;
|
|
36
|
+
/** The comparison, loaded while the drawer is open (`useEntryChangeData`). */
|
|
37
|
+
state: Pick<EntryChangeState, 'data' | 'error' | 'isValidating' | 'reload'>;
|
|
38
|
+
/** A referenced entry's title, from the entries the editor has listed. */
|
|
39
|
+
referenceTitle?: (id: string) => string | undefined;
|
|
40
|
+
/** Closes the drawer and opens the entry; given only when the entry exists on the branch. */
|
|
41
|
+
onOpenEntry?: () => void;
|
|
42
|
+
/** The branch's pull request, offered where this drawer can't compare. */
|
|
43
|
+
pullRequestUrl?: string;
|
|
44
|
+
}
|
|
45
|
+
/** The drawer's body: its states, then the summary and one card per change. */
|
|
46
|
+
export declare const EntryChangesView: React.FC<Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>>;
|
|
47
|
+
/**
|
|
48
|
+
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
49
|
+
* and the form and preview wait behind its backdrop.
|
|
50
|
+
*/
|
|
51
|
+
export declare const EntryChangesDrawer: React.FC<EntryChangesDrawerProps>;
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The "Changes to <title>" drawer: one card per changed field of an entry, its value at the
|
|
5
|
+
* base beside its saved value on the branch. LazyEntryChangesDrawer loads it, so the diff model
|
|
6
|
+
* and jsdiff stay out of the editor's first chunk.
|
|
7
|
+
*/
|
|
8
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { Alert, Badge, Box, Button, Group, List, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
10
|
+
import { useMediaQuery } from '@mantine/hooks';
|
|
11
|
+
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPhotoOff, IconPlus, } from '@tabler/icons-react';
|
|
12
|
+
import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
|
|
13
|
+
import { useAssetContext } from '../context/index.js';
|
|
14
|
+
import { computeEntryDiff, } from '../entry-diff.js';
|
|
15
|
+
import { editorImageSrc } from '../media/editor-image-src.js';
|
|
16
|
+
import { formatRelativeTime } from '../relative-time.js';
|
|
17
|
+
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
18
|
+
import { focusReturnProps } from '../utils/focus-return.js';
|
|
19
|
+
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
20
|
+
const KIND_BADGES = {
|
|
21
|
+
added: { label: 'Added', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
|
|
22
|
+
removed: { label: 'Removed', icon: _jsx(IconMinus, { size: 10, "aria-hidden": true }) },
|
|
23
|
+
changed: { label: 'Changed', icon: _jsx(IconPencil, { size: 10, "aria-hidden": true }) },
|
|
24
|
+
moved: { label: 'Moved', icon: _jsx(IconArrowsMove, { size: 10, "aria-hidden": true }) },
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
|
|
28
|
+
* `red.2` in light, `red.6` at 30% over the dark card.
|
|
29
|
+
*/
|
|
30
|
+
const REMOVED_STYLE = {
|
|
31
|
+
background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
|
|
32
|
+
color: 'inherit',
|
|
33
|
+
textDecoration: 'line-through',
|
|
34
|
+
textDecorationThickness: 2,
|
|
35
|
+
};
|
|
36
|
+
/** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
|
|
37
|
+
const ADDED_STYLE = {
|
|
38
|
+
background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
|
|
39
|
+
color: 'inherit',
|
|
40
|
+
textDecoration: 'underline',
|
|
41
|
+
textDecorationThickness: 2,
|
|
42
|
+
};
|
|
43
|
+
const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
|
|
44
|
+
/** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
|
|
45
|
+
const COLLAPSED_LINES = 50;
|
|
46
|
+
const LONG_TEXT_LINES = 200;
|
|
47
|
+
/** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
|
|
48
|
+
const WHOLE_LINE_SHARE = 0.6;
|
|
49
|
+
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
50
|
+
const ordinal = (n) => {
|
|
51
|
+
const tens = n % 100;
|
|
52
|
+
const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
|
|
53
|
+
return `${n}${suffix}`;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
|
|
57
|
+
* so a screen reader hears one removal, not one per word.
|
|
58
|
+
*/
|
|
59
|
+
const sideRuns = (parts, side) => {
|
|
60
|
+
const runs = [];
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
if (side === 'before' ? part.added : part.removed)
|
|
63
|
+
continue;
|
|
64
|
+
const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
|
|
65
|
+
const last = runs[runs.length - 1];
|
|
66
|
+
const gap = runs[runs.length - 2];
|
|
67
|
+
if (mark && last && last.mark === mark) {
|
|
68
|
+
last.value += part.value;
|
|
69
|
+
}
|
|
70
|
+
else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
|
|
71
|
+
runs.pop();
|
|
72
|
+
gap.value += last.value + part.value;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
runs.push({ value: part.value, ...(mark && { mark }) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return runs;
|
|
79
|
+
};
|
|
80
|
+
const WHITESPACE_MARKS = {
|
|
81
|
+
' ': { shown: '·', said: 'space' },
|
|
82
|
+
'\t': { shown: '→', said: 'tab' },
|
|
83
|
+
'\n': { shown: '⏎', said: 'line break' },
|
|
84
|
+
};
|
|
85
|
+
/** A run's text; whitespace alone shows a mark for each character, said in words. */
|
|
86
|
+
const RunText = ({ value }) => {
|
|
87
|
+
if (value === '')
|
|
88
|
+
return _jsx("em", { children: "(blank line)" });
|
|
89
|
+
if (!/^\s+$/.test(value))
|
|
90
|
+
return _jsx(_Fragment, { children: value });
|
|
91
|
+
const chars = [...value];
|
|
92
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: chars.map((c) => WHITESPACE_MARKS[c]?.shown ?? c).join('') }), _jsx(VisuallyHidden, { component: "span", children: chars.map((c) => WHITESPACE_MARKS[c]?.said ?? 'space').join(', ') })] }));
|
|
93
|
+
};
|
|
94
|
+
const MARK_WORDS = {
|
|
95
|
+
removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
|
|
96
|
+
added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
100
|
+
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
101
|
+
*/
|
|
102
|
+
const MarkedText = ({ parts, side, unit, }) => {
|
|
103
|
+
const runs = sideRuns(parts, side);
|
|
104
|
+
const total = runs.reduce((n, run) => n + run.value.length, 0);
|
|
105
|
+
const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
|
|
106
|
+
const mark = side === 'before' ? 'removed' : 'added';
|
|
107
|
+
const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
|
|
108
|
+
return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
|
|
109
|
+
if (!run.mark)
|
|
110
|
+
return _jsx(React.Fragment, { children: run.value }, i);
|
|
111
|
+
const Tag = run.mark === 'removed' ? 'del' : 'ins';
|
|
112
|
+
const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
|
|
113
|
+
// Whitespace alone, or a blank line's label, has nothing for a line to cross.
|
|
114
|
+
const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
|
|
115
|
+
return (_jsxs(Tag, { style: shown, children: [!whole && (_jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].start })), _jsx(RunText, { value: run.value }), !whole && _jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].end })] }, i));
|
|
116
|
+
})] }));
|
|
117
|
+
};
|
|
118
|
+
const Gutter = ({ sign }) => (_jsx(Text, { size: "sm", c: "dimmed", "aria-hidden": true, ta: "center", style: { userSelect: 'none' }, children: sign ?? '' }));
|
|
119
|
+
const Skipped = ({ count, style }) => (_jsx(Text, { size: "xs", c: "dimmed", py: 2, style: style, children: plural(count, 'unchanged line', 'unchanged lines') }));
|
|
120
|
+
const LINE_GRID = {
|
|
121
|
+
display: 'grid',
|
|
122
|
+
gridTemplateColumns: '1rem minmax(0, 1fr) 1rem minmax(0, 1fr)',
|
|
123
|
+
columnGap: 'var(--mantine-spacing-xs)',
|
|
124
|
+
};
|
|
125
|
+
/** Consecutive changed rows of one kind past this many show only RUN_SHOWN of them until asked. */
|
|
126
|
+
const LONG_RUN = 50;
|
|
127
|
+
const RUN_SHOWN = 20;
|
|
128
|
+
/** `rows` split into runs: each long run of lines added, removed or changed is its own segment. */
|
|
129
|
+
const rowSegments = (rows) => {
|
|
130
|
+
const kindOf = (row) => row.kind !== 'change' ? undefined : !row.before ? 'added' : !row.after ? 'removed' : 'changed';
|
|
131
|
+
const segments = [];
|
|
132
|
+
let plain = [];
|
|
133
|
+
for (let i = 0; i < rows.length;) {
|
|
134
|
+
const side = kindOf(rows[i]);
|
|
135
|
+
let end = i + 1;
|
|
136
|
+
while (side && end < rows.length && kindOf(rows[end]) === side)
|
|
137
|
+
end++;
|
|
138
|
+
if (side && end - i > LONG_RUN) {
|
|
139
|
+
if (plain.length > 0)
|
|
140
|
+
segments.push({ rows: plain });
|
|
141
|
+
plain = [];
|
|
142
|
+
segments.push({ rows: rows.slice(i, end), long: side });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
plain.push(...rows.slice(i, end));
|
|
146
|
+
}
|
|
147
|
+
i = end;
|
|
148
|
+
}
|
|
149
|
+
if (plain.length > 0)
|
|
150
|
+
segments.push({ rows: plain });
|
|
151
|
+
return segments;
|
|
152
|
+
};
|
|
153
|
+
/** A long run's first rows, then the button that shows the rest and moves focus to them. */
|
|
154
|
+
const LongRun = ({ rows, side, renderRows }) => {
|
|
155
|
+
const [expanded, setExpanded] = useState(false);
|
|
156
|
+
const firstNew = useRef(null);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (expanded)
|
|
159
|
+
firstNew.current?.focus();
|
|
160
|
+
}, [expanded]);
|
|
161
|
+
if (expanded) {
|
|
162
|
+
return (_jsxs(_Fragment, { children: [renderRows(rows.slice(0, RUN_SHOWN)), renderRows(rows.slice(RUN_SHOWN), firstNew)] }));
|
|
163
|
+
}
|
|
164
|
+
return (_jsxs(_Fragment, { children: [renderRows(rows.slice(0, RUN_SHOWN)), _jsx(Box, { style: { gridColumn: '1 / -1' }, children: _jsxs(Button, { size: "compact-sm", variant: "subtle", onClick: () => setExpanded(true), children: ["Show all ", rows.length, " ", side, " lines"] }) })] }));
|
|
165
|
+
};
|
|
166
|
+
/** The props that make a row's first text cell a focus target, when `i` is the first row's. */
|
|
167
|
+
const focusTarget = (i, focusRef) => i === 0 && focusRef ? { ref: focusRef, tabIndex: -1 } : {};
|
|
168
|
+
/** Two columns from `md`: an unchanged line is read once, from Before. */
|
|
169
|
+
const twoColumnRows = (rows, focusRef) => rows.map((row, i) => {
|
|
170
|
+
if (row.kind === 'skipped') {
|
|
171
|
+
return _jsx(Skipped, { count: row.count, style: { gridColumn: '1 / -1' } }, i);
|
|
172
|
+
}
|
|
173
|
+
if (row.kind === 'same') {
|
|
174
|
+
const text = row.text || '\u00a0';
|
|
175
|
+
return (_jsxs(React.Fragment, { children: [_jsx(Gutter, {}), _jsx(Text, { size: "sm", style: TEXT_STYLE, ...focusTarget(i, focusRef), children: text }), _jsx(Gutter, {}), _jsx(Text, { size: "sm", style: TEXT_STYLE, "aria-hidden": true, children: text })] }, i));
|
|
176
|
+
}
|
|
177
|
+
return (_jsxs(React.Fragment, { children: [_jsx(Gutter, { sign: row.before ? '−' : undefined }), _jsx(Text, { size: "sm", style: TEXT_STYLE, ...focusTarget(row.before ? i : -1, focusRef), children: row.before ? _jsx(MarkedText, { parts: row.before, side: "before", unit: "line" }) : null }), _jsx(Gutter, { sign: row.after ? '+' : undefined }), _jsx(Text, { size: "sm", style: TEXT_STYLE, ...focusTarget(row.before ? -1 : i, focusRef), children: row.after ? _jsx(MarkedText, { parts: row.after, side: "after", unit: "line" }) : null })] }, i));
|
|
178
|
+
});
|
|
179
|
+
/** One column below `md`, with − and + gutters. */
|
|
180
|
+
const oneColumnRows = (rows, focusRef) => rows.flatMap((row, i) => {
|
|
181
|
+
if (row.kind === 'skipped') {
|
|
182
|
+
return [_jsx(Skipped, { count: row.count, style: { gridColumn: '1 / -1' } }, i)];
|
|
183
|
+
}
|
|
184
|
+
if (row.kind === 'same') {
|
|
185
|
+
return [
|
|
186
|
+
_jsx(Gutter, {}, `${i}g`),
|
|
187
|
+
_jsx(Text, { size: "sm", style: TEXT_STYLE, ...focusTarget(i, focusRef), children: row.text || '\u00a0' }, i),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
return ['before', 'after'].flatMap((side) => {
|
|
191
|
+
const parts = row[side];
|
|
192
|
+
if (!parts)
|
|
193
|
+
return [];
|
|
194
|
+
return [
|
|
195
|
+
_jsx(Gutter, { sign: side === 'before' ? '−' : '+' }, `${i}${side}g`),
|
|
196
|
+
_jsx(Text, { size: "sm", style: TEXT_STYLE, ...focusTarget(side === 'before' || !row.before ? i : -1, focusRef), children: _jsx(MarkedText, { parts: parts, side: side, unit: "line" }) }, `${i}${side}`),
|
|
197
|
+
];
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
/**
|
|
201
|
+
* A line diff as aligned rows, so Before and After stay level however many lines one side adds:
|
|
202
|
+
* from `md`, two columns with an empty cell opposite a line the other side lacks; below it, one
|
|
203
|
+
* column with − and + gutters. One layout renders, chosen by the theme's `md` breakpoint.
|
|
204
|
+
*/
|
|
205
|
+
const LineRows = ({ rows }) => {
|
|
206
|
+
const theme = useMantineTheme();
|
|
207
|
+
const narrow = useMediaQuery(`(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`, false, {
|
|
208
|
+
getInitialValueInEffect: false,
|
|
209
|
+
}) === true;
|
|
210
|
+
const renderRows = narrow ? oneColumnRows : twoColumnRows;
|
|
211
|
+
const segments = rowSegments(rows);
|
|
212
|
+
return (_jsxs(Box, { style: narrow ? { ...LINE_GRID, gridTemplateColumns: '1rem minmax(0, 1fr)' } : LINE_GRID, "data-testid": narrow ? 'entry-change-line-list' : 'entry-change-line-grid', children: [!narrow && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", c: "dimmed", style: { gridColumn: '1 / span 2' }, children: "Before" }), _jsx(Text, { size: "xs", c: "dimmed", style: { gridColumn: '3 / span 2' }, children: "After" })] })), segments.map((segment, i) => segment.long ? (_jsx(LongRun, { rows: segment.rows, side: segment.long, renderRows: renderRows }, i)) : (_jsx(React.Fragment, { children: renderRows(segment.rows) }, i)))] }));
|
|
213
|
+
};
|
|
214
|
+
const WordsChange = ({ text, side, }) => (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: text.parts, side: side, unit: "text" }) }));
|
|
215
|
+
const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
|
|
216
|
+
/** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
|
|
217
|
+
const Thumbnail = ({ src }) => {
|
|
218
|
+
const { baseUrl } = useAssetContext();
|
|
219
|
+
const [failed, setFailed] = useState(false);
|
|
220
|
+
if (failed) {
|
|
221
|
+
return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
222
|
+
}
|
|
223
|
+
return (_jsx("img", { src: editorImageSrc(src, baseUrl, { width: EDITOR_THUMBNAIL_WIDTHS.listRow }), alt: "", width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, onError: () => setFailed(true), style: { objectFit: 'cover', borderRadius: 4, flexShrink: 0 } }));
|
|
224
|
+
};
|
|
225
|
+
/** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
|
|
226
|
+
const LongText = ({ text }) => {
|
|
227
|
+
const [expanded, setExpanded] = useState(false);
|
|
228
|
+
const firstNew = useRef(null);
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (expanded)
|
|
231
|
+
firstNew.current?.focus();
|
|
232
|
+
}, [expanded]);
|
|
233
|
+
const lines = text.split('\n');
|
|
234
|
+
const long = lines.length > LONG_TEXT_LINES;
|
|
235
|
+
const collapsed = !expanded && long;
|
|
236
|
+
return (_jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: !long ? (text) : (_jsxs(_Fragment, { children: [lines.slice(0, COLLAPSED_LINES).join('\n'), expanded && (_jsxs(_Fragment, { children: ['\n', _jsx("span", { ref: firstNew, tabIndex: -1, children: lines.slice(COLLAPSED_LINES).join('\n') })] }))] })) }), collapsed && (_jsxs(Button, { size: "compact-sm", variant: "subtle", onClick: () => setExpanded(true), children: ["Show all ", lines.length, " lines"] }))] }));
|
|
237
|
+
};
|
|
238
|
+
/** A value as the form would show it: titles for references, file names for images. */
|
|
239
|
+
const Value = ({ value, alt, }) => {
|
|
240
|
+
switch (value.kind) {
|
|
241
|
+
case 'empty':
|
|
242
|
+
return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
|
|
243
|
+
case 'text':
|
|
244
|
+
return _jsx(LongText, { text: value.text });
|
|
245
|
+
case 'number':
|
|
246
|
+
return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
|
|
247
|
+
case 'boolean':
|
|
248
|
+
return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
|
|
249
|
+
case 'date':
|
|
250
|
+
return _jsx(DateValue, { value: value });
|
|
251
|
+
case 'select':
|
|
252
|
+
return _jsx(Text, { size: "sm", children: value.label });
|
|
253
|
+
case 'reference':
|
|
254
|
+
return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
|
|
255
|
+
case 'image':
|
|
256
|
+
return (_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(Thumbnail, { src: value.src }, value.src), _jsxs(Stack, { gap: 0, miw: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: value.name }), value.alt && (_jsxs(Text, { size: "xs", style: TEXT_STYLE, children: [_jsxs(Text, { span: true, size: "xs", c: "dimmed", children: ["Alt text:", ' '] }), alt?.text.mode === 'words' ? (_jsx(MarkedText, { parts: alt.text.parts, side: alt.side, unit: "text" })) : (value.alt)] }))] })] }));
|
|
257
|
+
case 'position':
|
|
258
|
+
return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
|
|
259
|
+
case 'list':
|
|
260
|
+
return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
|
|
261
|
+
case 'fields':
|
|
262
|
+
return (_jsx(Stack, { gap: 6, children: value.fields.map((field, i) => (_jsxs("div", { children: [_jsx(Text, { size: "xs", fw: 500, children: field.label }), _jsx(Value, { value: field.value })] }, i))) }));
|
|
263
|
+
case 'raw':
|
|
264
|
+
return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
269
|
+
* place the editor shows a date absolute first and relative after.
|
|
270
|
+
*/
|
|
271
|
+
const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), _jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.dateOnly ? `${value.iso}T00:00:00` : value.iso) })] }));
|
|
272
|
+
const SideColumn = ({ label, change, side, }) => {
|
|
273
|
+
const value = change[side] ?? { kind: 'empty' };
|
|
274
|
+
const words = change.text?.mode === 'words' ? change.text : undefined;
|
|
275
|
+
return (_jsxs(Stack, { gap: 4, miw: 0, children: [_jsx(Text, { size: "xs", c: "dimmed", children: label }), value.kind === 'date' && words ? (_jsx(DateValue, { value: value, text: _jsx(MarkedText, { parts: words.parts, side: side, unit: "text" }) })) : words ? (_jsx(WordsChange, { text: words, side: side })) : (_jsx(Value, { value: value, alt: change.altText && { text: change.altText, side } }))] }));
|
|
276
|
+
};
|
|
277
|
+
/** One change: its labelled breadcrumb, its kind in a word, and the values on each side. */
|
|
278
|
+
const ChangeCard = ({ change, wholeEntry, }) => {
|
|
279
|
+
const moved = change.kind === 'moved' &&
|
|
280
|
+
change.before?.kind === 'position' &&
|
|
281
|
+
change.after?.kind === 'position'
|
|
282
|
+
? { from: change.before.position, to: change.after.position, of: change.after.of }
|
|
283
|
+
: undefined;
|
|
284
|
+
return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." }))] }));
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* The summary's place, a status region from the first render on, so a screen reader hears
|
|
288
|
+
* "Loading changes…" and then the summary that replaces it, each once.
|
|
289
|
+
*/
|
|
290
|
+
const StatusRegion = ({ children }) => (_jsx(Stack, { gap: 2, role: "status", "data-testid": "entry-changes-status", children: children }));
|
|
291
|
+
const REFUSALS = {
|
|
292
|
+
tooLarge: 'This entry is too large to compare here.',
|
|
293
|
+
// A path the user can't read answers 404 too, so this never says the entry doesn't exist.
|
|
294
|
+
notFound: 'This entry can’t be compared here.',
|
|
295
|
+
unreadable: 'This entry couldn’t be read on one side, so it can’t be compared.',
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* "3 fields changed since main"; whole list items are counted apart from fields:
|
|
299
|
+
* "6 fields changed, 2 items added, 1 item moved since main".
|
|
300
|
+
*/
|
|
301
|
+
const changeSummary = (changes, base) => {
|
|
302
|
+
const items = (kind) => changes.filter((c) => c.item && c.kind === kind).length;
|
|
303
|
+
const fields = changes.filter((c) => !c.item).length;
|
|
304
|
+
const parts = [
|
|
305
|
+
...(fields > 0 ? [`${plural(fields, 'field', 'fields')} changed`] : []),
|
|
306
|
+
...['added', 'removed', 'moved'].flatMap((kind) => {
|
|
307
|
+
const count = items(kind);
|
|
308
|
+
return count > 0 ? [`${plural(count, 'item', 'items')} ${kind}`] : [];
|
|
309
|
+
}),
|
|
310
|
+
];
|
|
311
|
+
return `${parts.join(', ')} since ${base}`;
|
|
312
|
+
};
|
|
313
|
+
/** The drawer's body: its states, then the summary and one card per change. */
|
|
314
|
+
export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl }) => {
|
|
315
|
+
const { data, error } = state;
|
|
316
|
+
const diff = useMemo(() => data &&
|
|
317
|
+
computeEntryDiff({
|
|
318
|
+
fields: subject.fields,
|
|
319
|
+
before: data.before,
|
|
320
|
+
after: data.after,
|
|
321
|
+
referenceTitle,
|
|
322
|
+
}), [data, subject.fields, referenceTitle]);
|
|
323
|
+
// A refusal no retry passes: its reason, and the ways the entry can still be looked at.
|
|
324
|
+
const refusal = error?.kind === 'tooLarge' || error?.kind === 'notFound' || error?.kind === 'unreadable'
|
|
325
|
+
? error.kind
|
|
326
|
+
: undefined;
|
|
327
|
+
if (refusal) {
|
|
328
|
+
return (_jsxs(Stack, { gap: "xs", align: "flex-start", "data-testid": `entry-changes-${refusal}`, children: [_jsx(Text, { size: "sm", children: REFUSALS[refusal] }), _jsxs(Group, { gap: "md", children: [onOpenEntry && refusal !== 'notFound' && (_jsx(Button, { size: "compact-sm", variant: "subtle", px: 0, onClick: onOpenEntry, children: "Open the entry" })), pullRequestUrl && (_jsxs(Button, { component: "a", href: pullRequestUrl, target: "_blank", rel: "noopener noreferrer", size: "compact-sm", variant: "subtle", px: 0, rightSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), children: ["Compare on the pull request", _jsx(VisuallyHidden, { component: "span", children: " (opens in a new tab)" })] }))] })] }));
|
|
329
|
+
}
|
|
330
|
+
const errorNotice = error && (_jsx(Alert, { variant: "light", color: error.kind === 'syncing' ? 'yellow' : 'red', icon: error.kind === 'syncing' ? _jsx(IconAlertTriangle, { size: 16 }) : _jsx(IconAlertCircle, { size: 16 }), "data-testid": `entry-changes-${error.kind}`, children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: error.kind === 'syncing'
|
|
331
|
+
? `${error.message ?? `This branch is being synced with ${base}. Try again in a moment.`}${data ? ' The changes below may be out of date.' : ''}`
|
|
332
|
+
: data
|
|
333
|
+
? 'Couldn’t refresh this entry’s changes. They may be out of date.'
|
|
334
|
+
: 'Couldn’t load this entry’s changes.' }), _jsx(Button, { size: "compact-sm", variant: "subtle", loading: state.isValidating, onClick: () => void state.reload(), children: "Retry" })] }) }));
|
|
335
|
+
if (!data || !diff) {
|
|
336
|
+
return (_jsxs(Stack, { gap: "sm", children: [_jsx(StatusRegion, { children: !errorNotice && _jsx(LoadingSummary, {}) }), errorNotice || _jsx(LoadingCards, {})] }));
|
|
337
|
+
}
|
|
338
|
+
const wholeEntry = !data.before ? 'after' : !data.after ? 'before' : undefined;
|
|
339
|
+
const count = diff.changes.length;
|
|
340
|
+
const movedFrom = data.before && data.after ? subject.movedFromLabel : undefined;
|
|
341
|
+
// A move that changed no value is said by its "Moved from" line alone.
|
|
342
|
+
const summary = !data.before
|
|
343
|
+
? 'New entry'
|
|
344
|
+
: !data.after
|
|
345
|
+
? 'Deleted entry'
|
|
346
|
+
: count > 0
|
|
347
|
+
? changeSummary(diff.changes, base)
|
|
348
|
+
: movedFrom
|
|
349
|
+
? undefined
|
|
350
|
+
: 'Only formatting changed; no field values differ.';
|
|
351
|
+
return (_jsxs(Stack, { gap: "sm", children: [_jsxs(StatusRegion, { children: [movedFrom && (_jsxs(Text, { size: "sm", style: { overflowWrap: 'anywhere' }, "data-testid": "entry-changes-moved", children: ["Moved from ", movedFrom] })), summary && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-summary", children: summary }))] }), errorNotice, subject.unsavedEdits && (_jsx(Alert, { variant: "light", color: "blue", icon: _jsx(IconInfoCircle, { size: 16 }), "data-testid": "entry-changes-unsaved", children: _jsx(Text, { size: "sm", children: "Unsaved edits aren\u2019t included. Save to include them." }) })), count > 0 && (_jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: diff.changes.map((change) => (_jsx(ChangeCard, { change: change, wholeEntry: wholeEntry }, change.key))) }))] }));
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
355
|
+
* and the form and preview wait behind its backdrop.
|
|
356
|
+
*/
|
|
357
|
+
export const EntryChangesDrawer = ({ opened, onClose, onExited, returnFocusTo, ...view }) => {
|
|
358
|
+
const focusReturn = focusReturnProps(returnFocusTo);
|
|
359
|
+
return (_jsx(OverlayDrawer, { opened: opened, onClose: onClose, position: "right", size: 720, title: `Changes to ${view.subject.title}`, "data-testid": "entry-changes-drawer", ...focusReturn, onExitTransitionEnd: () => {
|
|
360
|
+
focusReturn.onExitTransitionEnd?.();
|
|
361
|
+
onExited?.();
|
|
362
|
+
}, children: _jsx(EntryChangesView, { ...view }) }));
|
|
363
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Show changes" drawer's loading state, shared by the drawer and by the fallback that
|
|
3
|
+
* LazyEntryChangesDrawer shows while the drawer's own chunk loads.
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
/** The summary line's placeholder, with the words a screen reader hears in its status region. */
|
|
7
|
+
export declare const LoadingSummary: React.FC;
|
|
8
|
+
/** Cards' placeholders. */
|
|
9
|
+
export declare const LoadingCards: React.FC;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Skeleton, Stack, VisuallyHidden } from '@mantine/core';
|
|
4
|
+
/** The summary line's placeholder, with the words a screen reader hears in its status region. */
|
|
5
|
+
export const LoadingSummary = () => (_jsxs(_Fragment, { children: [_jsx(VisuallyHidden, { children: "Loading changes\u2026" }), _jsx(Skeleton, { height: 22, width: "45%" })] }));
|
|
6
|
+
/** Cards' placeholders. */
|
|
7
|
+
export const LoadingCards = () => (_jsx(Stack, { gap: "sm", "data-testid": "entry-changes-loading", "aria-busy": "true", children: [0, 1, 2].map((i) => (_jsx(Skeleton, { height: 96, radius: "sm" }, i))) }));
|
|
@@ -17,6 +17,14 @@ export interface EntryHeaderProps {
|
|
|
17
17
|
onEntryReveal?: () => void;
|
|
18
18
|
/** The entry menu's trigger. */
|
|
19
19
|
navigatorToggleRef?: Ref<HTMLButtonElement>;
|
|
20
|
+
/** Opens the open entry's changes against the base; offered only while it has some. */
|
|
21
|
+
onShowChanges?: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Hold the Show changes slot, unseen, when there is no `onShowChanges`: while the changes list
|
|
24
|
+
* loads, or for an entry with no changes, so the header never shifts between them.
|
|
25
|
+
*/
|
|
26
|
+
showChangesPending?: boolean;
|
|
27
|
+
showChangesRef?: Ref<HTMLButtonElement>;
|
|
20
28
|
/** Replaces the open entry with the server's copy. */
|
|
21
29
|
onFileReload: () => void;
|
|
22
30
|
onFileDiscardDraft: () => void;
|
|
@@ -61,4 +69,4 @@ export interface EntryHeaderProps {
|
|
|
61
69
|
branchPullRequestClosed?: boolean;
|
|
62
70
|
}
|
|
63
71
|
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
64
|
-
export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
72
|
+
export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Box, Group } from '@mantine/core';
|
|
3
|
+
import { ActionIcon, Box, Button, em, getBreakpointValue, Group, Tooltip, useMantineTheme, } from '@mantine/core';
|
|
4
|
+
import { useMediaQuery } from '@mantine/hooks';
|
|
5
|
+
import { IconArrowsDiff } from '@tabler/icons-react';
|
|
4
6
|
import { SHELL_HEADER_HEIGHT, SHELL_REGION_BORDER } from '../shell/EditorShell.js';
|
|
5
7
|
import { describeBranchLock } from '../branch-lock.js';
|
|
8
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
6
9
|
import { EntryBreadcrumb } from './EntryBreadcrumb.js';
|
|
7
10
|
import { EntrySaveButton } from './EntrySaveButton.js';
|
|
11
|
+
/**
|
|
12
|
+
* "Show changes": a subtle text button, icon-only below `sm`. Without `onClick` it holds its
|
|
13
|
+
* place unseen while the changes list loads, so nothing beside it moves when it appears.
|
|
14
|
+
*/
|
|
15
|
+
const ShowChangesButton = ({ onClick, buttonRef, }) => {
|
|
16
|
+
const theme = useMantineTheme();
|
|
17
|
+
const narrow = useMediaQuery(`(max-width: ${em(getBreakpointValue('sm', theme.breakpoints) - 0.1)})`, false, {
|
|
18
|
+
getInitialValueInEffect: false,
|
|
19
|
+
}) === true;
|
|
20
|
+
const placeholder = !onClick;
|
|
21
|
+
const hold = placeholder
|
|
22
|
+
? { 'aria-hidden': true, tabIndex: -1, style: { visibility: 'hidden', flexShrink: 0 } }
|
|
23
|
+
: { style: { flexShrink: 0 } };
|
|
24
|
+
const icon = _jsx(IconArrowsDiff, { size: 14, "aria-hidden": true });
|
|
25
|
+
if (narrow) {
|
|
26
|
+
return (_jsx(Tooltip, { label: EDITOR_ACTIONS.showChanges, position: "bottom", events: { hover: true, focus: true, touch: false }, disabled: placeholder, children: _jsx(ActionIcon, { ref: placeholder ? undefined : buttonRef, size: "md", variant: "subtle", "aria-label": EDITOR_ACTIONS.showChanges, onClick: onClick, "data-testid": placeholder ? 'entry-show-changes-pending' : 'entry-show-changes', ...hold, children: icon }) }));
|
|
27
|
+
}
|
|
28
|
+
return (_jsx(Button, { ref: placeholder ? undefined : buttonRef, size: "compact-sm", variant: "subtle", leftSection: icon, onClick: onClick, "data-testid": placeholder ? 'entry-show-changes-pending' : 'entry-show-changes', ...hold, children: EDITOR_ACTIONS.showChanges }));
|
|
29
|
+
};
|
|
8
30
|
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
9
|
-
export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, }) {
|
|
31
|
+
export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, }) {
|
|
10
32
|
const branchLockReason = describeBranchLock({
|
|
11
33
|
branchStatus,
|
|
12
34
|
branchReadOnly,
|
|
@@ -40,7 +62,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
|
|
|
40
62
|
entryNoPage: !!currentEntry && !currentEntry.previewSrc, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
|
|
41
63
|
(entryUnavailable
|
|
42
64
|
? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
|
|
43
|
-
: undefined), entryMenuRef: navigatorToggleRef }) }), _jsx(EntrySaveButton, { onSave: onSave, saveBlockedReason: saveBlockedReason,
|
|
65
|
+
: undefined), entryMenuRef: navigatorToggleRef }) }), (onShowChanges || showChangesPending) && (_jsx(ShowChangesButton, { onClick: onShowChanges, buttonRef: showChangesRef })), _jsx(EntrySaveButton, { onSave: onSave, saveBlockedReason: saveBlockedReason,
|
|
44
66
|
// Loading, "Open an entry to save it." would claim an empty state too soon.
|
|
45
67
|
busy: busy || entryLoading, saveAllRunning: saveAllRunning, saving: saving, savedSignal: savedSignal, savedHere: savedHere, entryDirty: hasUnsavedChanges, draftStorageFailed: draftStorageFailed })] }));
|
|
46
68
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Show changes" drawer as Editor.tsx mounts it: loaded on first open with `React.lazy`, so
|
|
3
|
+
* the diff model and jsdiff stay out of the editor's first chunk, and behind an error boundary,
|
|
4
|
+
* so a chunk that fails to load or a comparison that throws costs the drawer, not the editor.
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import type { EntryChangesDrawerProps } from './EntryChangesDrawer.js';
|
|
8
|
+
type DrawerModule = {
|
|
9
|
+
default: React.ComponentType<EntryChangesDrawerProps>;
|
|
10
|
+
};
|
|
11
|
+
export interface LazyEntryChangesDrawerProps extends EntryChangesDrawerProps {
|
|
12
|
+
/** @internal The chunk's loader, for tests. */
|
|
13
|
+
load?: () => Promise<DrawerModule>;
|
|
14
|
+
}
|
|
15
|
+
export declare const LazyEntryChangesDrawer: React.FC<LazyEntryChangesDrawerProps>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The "Show changes" drawer as Editor.tsx mounts it: loaded on first open with `React.lazy`, so
|
|
5
|
+
* the diff model and jsdiff stay out of the editor's first chunk, and behind an error boundary,
|
|
6
|
+
* so a chunk that fails to load or a comparison that throws costs the drawer, not the editor.
|
|
7
|
+
*/
|
|
8
|
+
import React, { Suspense, useState } from 'react';
|
|
9
|
+
import { Alert, Button, Stack, Text } from '@mantine/core';
|
|
10
|
+
import { IconAlertCircle } from '@tabler/icons-react';
|
|
11
|
+
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
12
|
+
import { focusReturnProps } from '../utils/focus-return.js';
|
|
13
|
+
import { EditorErrorBoundary } from './EditorErrorBoundary.js';
|
|
14
|
+
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
15
|
+
const loadDrawer = () => import('./EntryChangesDrawer.js').then((m) => ({ default: m.EntryChangesDrawer }));
|
|
16
|
+
export const LazyEntryChangesDrawer = ({ load = loadDrawer, ...props }) => {
|
|
17
|
+
const [opening, setOpening] = useState(() => ({
|
|
18
|
+
opened: props.opened,
|
|
19
|
+
seq: props.opened ? 1 : 0,
|
|
20
|
+
failed: false,
|
|
21
|
+
Drawer: React.lazy(load),
|
|
22
|
+
}));
|
|
23
|
+
if (props.opened !== opening.opened) {
|
|
24
|
+
const reopened = props.opened && !opening.opened;
|
|
25
|
+
setOpening({
|
|
26
|
+
opened: props.opened,
|
|
27
|
+
seq: opening.seq + (reopened ? 1 : 0),
|
|
28
|
+
failed: reopened ? false : opening.failed,
|
|
29
|
+
Drawer: reopened && opening.failed ? React.lazy(load) : opening.Drawer,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const { Drawer } = opening;
|
|
33
|
+
const focusReturn = focusReturnProps(props.returnFocusTo);
|
|
34
|
+
if (opening.seq === 0)
|
|
35
|
+
return null;
|
|
36
|
+
return (_jsx(EditorErrorBoundary, { context: { boundary: 'changes' },
|
|
37
|
+
// Each opening, and each entry, starts clear of an earlier one's failure.
|
|
38
|
+
resetKey: `${props.subject.logicalPath}#${opening.seq}`, onCaught: () => setOpening((current) => ({ ...current, failed: true })), fallback: () => (_jsx(OverlayDrawer, { opened: props.opened, onClose: props.onClose, position: "right", size: 720, title: `Changes to ${props.subject.title}`, ...focusReturn, onExitTransitionEnd: () => {
|
|
39
|
+
focusReturn.onExitTransitionEnd?.();
|
|
40
|
+
props.onExited?.();
|
|
41
|
+
}, children: _jsx(Alert, { variant: "light", color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), "data-testid": "entry-changes-crashed", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: "Changes couldn\u2019t be shown. Close and reopen to try again, or reload the page." }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: props.onClose, children: "Close" })] }) }) })), children: _jsx(Suspense, { fallback: _jsx(OverlayDrawer, { opened: props.opened, onClose: props.onClose, position: "right", size: 720, title: `Changes to ${props.subject.title}`, ...focusReturn, onExitTransitionEnd: () => {
|
|
42
|
+
focusReturn.onExitTransitionEnd?.();
|
|
43
|
+
props.onExited?.();
|
|
44
|
+
}, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Stack, { gap: 2, role: "status", children: _jsx(LoadingSummary, {}) }), _jsx(LoadingCards, {})] }) }), children: _jsx(Drawer, { ...props }) }) }));
|
|
45
|
+
};
|