canopycms 0.12.0 → 0.12.1
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 +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +429 -52
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +90 -0
- package/dist/editor/conflict-cards.js +279 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +5 -0
- package/dist/git-manager.js +30 -0
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Button, Text } from '@mantine/core';
|
|
5
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
6
|
+
import { releaseHoldConsequences } from '../conflict-notice.js';
|
|
7
|
+
import { ConfirmDeleteModal } from './ConfirmDeleteModal.js';
|
|
8
|
+
/**
|
|
9
|
+
* An admin's way out of a branch held because its conflict record was set aside
|
|
10
|
+
* (api/merge-conflicts.ts `releaseHold`), behind a confirm that says what is lost. A refusal shows
|
|
11
|
+
* in the confirm, which stays open; a release closes it.
|
|
12
|
+
*/
|
|
13
|
+
export function ReleaseHoldButton({ branchLabel, onRelease, }) {
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
const [releasing, setReleasing] = useState(false);
|
|
16
|
+
const [refusal, setRefusal] = useState();
|
|
17
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { size: "compact-sm", variant: "default", style: { flexShrink: 0 }, onClick: () => {
|
|
18
|
+
setRefusal(undefined);
|
|
19
|
+
setOpen(true);
|
|
20
|
+
}, "data-testid": "release-hold", children: EDITOR_ACTIONS.releaseHold }), _jsx(ConfirmDeleteModal, { isOpen: open, title: branchLabel ? `Release the hold on "${branchLabel}"?` : "Release this branch's hold?", message: releaseHoldConsequences, confirmLabel: EDITOR_ACTIONS.releaseHold, loading: releasing, onClose: () => setOpen(false), onConfirm: async () => {
|
|
21
|
+
setReleasing(true);
|
|
22
|
+
setRefusal(undefined);
|
|
23
|
+
try {
|
|
24
|
+
const outcome = await onRelease();
|
|
25
|
+
if ('released' in outcome)
|
|
26
|
+
setOpen(false);
|
|
27
|
+
else
|
|
28
|
+
setRefusal(outcome);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setReleasing(false);
|
|
32
|
+
}
|
|
33
|
+
}, children: refusal && (_jsx(Text, { size: "sm", role: "alert", c: refusal.tone === 'warning'
|
|
34
|
+
? 'var(--mantine-color-yellow-text)'
|
|
35
|
+
: 'var(--mantine-color-red-text)', "data-testid": "release-hold-refusal", children: refusal.error })) })] }));
|
|
36
|
+
}
|
|
@@ -6,5 +6,5 @@ import { Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
|
|
|
6
6
|
export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', details, }) {
|
|
7
7
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
8
8
|
const detailsId = useId();
|
|
9
|
-
return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
|
|
9
|
+
return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, style: { flexShrink: 0 }, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
|
|
10
10
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the "Show changes" drawer shows a value and a text change, shared by its change cards and its
|
|
3
|
+
* conflict cards: marked text, values as the form would show them, and the drawer's reasoned button.
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import type { DiffPart, DisplayValue, TextDiff } from '../entry-diff.js';
|
|
7
|
+
export declare const TEXT_STYLE: React.CSSProperties;
|
|
8
|
+
export declare const plural: (count: number, one: string, many: string) => string;
|
|
9
|
+
export declare const ordinal: (n: number) => string;
|
|
10
|
+
export type Side = 'before' | 'after';
|
|
11
|
+
/**
|
|
12
|
+
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
13
|
+
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MarkedText: React.FC<{
|
|
16
|
+
parts: readonly DiffPart[];
|
|
17
|
+
side: Side;
|
|
18
|
+
unit: 'line' | 'text';
|
|
19
|
+
}>;
|
|
20
|
+
/** A value as the form would show it: titles for references, file names for images. */
|
|
21
|
+
export declare const Value: React.FC<{
|
|
22
|
+
value: DisplayValue;
|
|
23
|
+
alt?: {
|
|
24
|
+
text: TextDiff;
|
|
25
|
+
side: Side;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
30
|
+
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
31
|
+
* may be a future event, and a past one is not hours ago.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DateValue: React.FC<{
|
|
34
|
+
value: Extract<DisplayValue, {
|
|
35
|
+
kind: 'date';
|
|
36
|
+
}>;
|
|
37
|
+
text?: React.ReactNode;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
|
|
41
|
+
* why (`data-disabled` keeps the Tooltip working on focus and touch).
|
|
42
|
+
*/
|
|
43
|
+
export declare const ReasonedButton: React.FC<{
|
|
44
|
+
reason?: string;
|
|
45
|
+
onClick: () => void;
|
|
46
|
+
children: React.ReactNode;
|
|
47
|
+
danger?: boolean;
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
'aria-label'?: string;
|
|
50
|
+
'data-testid'?: string;
|
|
51
|
+
}>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* How the "Show changes" drawer shows a value and a text change, shared by its change cards and its
|
|
5
|
+
* conflict cards: marked text, values as the form would show them, and the drawer's reasoned button.
|
|
6
|
+
*/
|
|
7
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
8
|
+
import { Button, Group, List, Stack, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
9
|
+
import { IconPhotoOff } from '@tabler/icons-react';
|
|
10
|
+
import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
|
|
11
|
+
import { useAssetContext } from '../context/index.js';
|
|
12
|
+
import { editorImageSrc } from '../media/editor-image-src.js';
|
|
13
|
+
import { formatRelativeTime } from '../relative-time.js';
|
|
14
|
+
/**
|
|
15
|
+
* Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
|
|
16
|
+
* `red.2` in light, `red.6` at 30% over the dark card.
|
|
17
|
+
*/
|
|
18
|
+
const REMOVED_STYLE = {
|
|
19
|
+
background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
|
|
20
|
+
color: 'inherit',
|
|
21
|
+
textDecoration: 'line-through',
|
|
22
|
+
textDecorationThickness: 2,
|
|
23
|
+
};
|
|
24
|
+
/** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
|
|
25
|
+
const ADDED_STYLE = {
|
|
26
|
+
background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
|
|
27
|
+
color: 'inherit',
|
|
28
|
+
textDecoration: 'underline',
|
|
29
|
+
textDecorationThickness: 2,
|
|
30
|
+
};
|
|
31
|
+
export const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
|
|
32
|
+
/** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
|
|
33
|
+
const COLLAPSED_LINES = 50;
|
|
34
|
+
const LONG_TEXT_LINES = 200;
|
|
35
|
+
/** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
|
|
36
|
+
const WHOLE_LINE_SHARE = 0.6;
|
|
37
|
+
export const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
38
|
+
export const ordinal = (n) => {
|
|
39
|
+
const tens = n % 100;
|
|
40
|
+
const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
|
|
41
|
+
return `${n}${suffix}`;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
|
|
45
|
+
* so a screen reader hears one removal, not one per word.
|
|
46
|
+
*/
|
|
47
|
+
const sideRuns = (parts, side) => {
|
|
48
|
+
const runs = [];
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (side === 'before' ? part.added : part.removed)
|
|
51
|
+
continue;
|
|
52
|
+
const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
|
|
53
|
+
const last = runs[runs.length - 1];
|
|
54
|
+
const gap = runs[runs.length - 2];
|
|
55
|
+
if (mark && last && last.mark === mark) {
|
|
56
|
+
last.value += part.value;
|
|
57
|
+
}
|
|
58
|
+
else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
|
|
59
|
+
runs.pop();
|
|
60
|
+
gap.value += last.value + part.value;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
runs.push({ value: part.value, ...(mark && { mark }) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return runs;
|
|
67
|
+
};
|
|
68
|
+
const WHITESPACE_MARKS = {
|
|
69
|
+
' ': { shown: '·', said: 'space' },
|
|
70
|
+
'\t': { shown: '→', said: 'tab' },
|
|
71
|
+
'\n': { shown: '⏎', said: 'line break' },
|
|
72
|
+
};
|
|
73
|
+
/** A run's text; whitespace alone shows a mark for each character, said in words. */
|
|
74
|
+
const RunText = ({ value }) => {
|
|
75
|
+
if (value === '')
|
|
76
|
+
return _jsx("em", { children: "(blank line)" });
|
|
77
|
+
if (!/^\s+$/.test(value))
|
|
78
|
+
return _jsx(_Fragment, { children: value });
|
|
79
|
+
const chars = [...value];
|
|
80
|
+
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(', ') })] }));
|
|
81
|
+
};
|
|
82
|
+
const MARK_WORDS = {
|
|
83
|
+
removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
|
|
84
|
+
added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
88
|
+
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
89
|
+
*/
|
|
90
|
+
export const MarkedText = ({ parts, side, unit }) => {
|
|
91
|
+
const runs = sideRuns(parts, side);
|
|
92
|
+
const total = runs.reduce((n, run) => n + run.value.length, 0);
|
|
93
|
+
const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
|
|
94
|
+
const mark = side === 'before' ? 'removed' : 'added';
|
|
95
|
+
const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
|
|
96
|
+
return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
|
|
97
|
+
if (!run.mark)
|
|
98
|
+
return _jsx(React.Fragment, { children: run.value }, i);
|
|
99
|
+
const Tag = run.mark === 'removed' ? 'del' : 'ins';
|
|
100
|
+
const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
|
|
101
|
+
// Whitespace alone, or a blank line's label, has nothing for a line to cross.
|
|
102
|
+
const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
|
|
103
|
+
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));
|
|
104
|
+
})] }));
|
|
105
|
+
};
|
|
106
|
+
const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
|
|
107
|
+
/** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
|
|
108
|
+
const Thumbnail = ({ src }) => {
|
|
109
|
+
const { baseUrl } = useAssetContext();
|
|
110
|
+
const [failed, setFailed] = useState(false);
|
|
111
|
+
if (failed) {
|
|
112
|
+
return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
113
|
+
}
|
|
114
|
+
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 } }));
|
|
115
|
+
};
|
|
116
|
+
/** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
|
|
117
|
+
const LongText = ({ text }) => {
|
|
118
|
+
const [expanded, setExpanded] = useState(false);
|
|
119
|
+
const firstNew = useRef(null);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (expanded)
|
|
122
|
+
firstNew.current?.focus();
|
|
123
|
+
}, [expanded]);
|
|
124
|
+
const lines = text.split('\n');
|
|
125
|
+
const long = lines.length > LONG_TEXT_LINES;
|
|
126
|
+
const collapsed = !expanded && long;
|
|
127
|
+
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"] }))] }));
|
|
128
|
+
};
|
|
129
|
+
/** A value as the form would show it: titles for references, file names for images. */
|
|
130
|
+
export const Value = ({ value, alt, }) => {
|
|
131
|
+
switch (value.kind) {
|
|
132
|
+
case 'empty':
|
|
133
|
+
return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
|
|
134
|
+
case 'text':
|
|
135
|
+
return _jsx(LongText, { text: value.text });
|
|
136
|
+
case 'number':
|
|
137
|
+
return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
|
|
138
|
+
case 'boolean':
|
|
139
|
+
return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
|
|
140
|
+
case 'date':
|
|
141
|
+
return _jsx(DateValue, { value: value });
|
|
142
|
+
case 'select':
|
|
143
|
+
return _jsx(Text, { size: "sm", children: value.label });
|
|
144
|
+
case 'reference':
|
|
145
|
+
return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
|
|
146
|
+
case 'image':
|
|
147
|
+
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)] }))] })] }));
|
|
148
|
+
case 'position':
|
|
149
|
+
return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
|
|
150
|
+
case 'list':
|
|
151
|
+
return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
|
|
152
|
+
case 'fields':
|
|
153
|
+
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))) }));
|
|
154
|
+
case 'raw':
|
|
155
|
+
return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
160
|
+
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
161
|
+
* may be a future event, and a past one is not hours ago.
|
|
162
|
+
*/
|
|
163
|
+
export const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), !value.dateOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.iso) }))] }));
|
|
164
|
+
/**
|
|
165
|
+
* A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
|
|
166
|
+
* why (`data-disabled` keeps the Tooltip working on focus and touch).
|
|
167
|
+
*/
|
|
168
|
+
export const ReasonedButton = ({ reason, onClick, danger, ...props }) => (_jsx(Tooltip, { label: reason, disabled: !reason, multiline: true, maw: 280, position: "bottom-start", children: _jsx(Button, { size: "compact-sm", variant: "subtle", ...(danger && { color: 'red', c: 'var(--canopy-danger-text)' }), ...props, onClick: () => {
|
|
169
|
+
if (!reason)
|
|
170
|
+
onClick();
|
|
171
|
+
}, "data-disabled": reason ? true : undefined, "aria-disabled": reason ? true : undefined }) }));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cards "Show changes" shows for an entry's open conflicts, built from what
|
|
3
|
+
* `GET /:branch/conflicts/...entryPath` answers (api/merge-conflicts.ts): each place's three
|
|
4
|
+
* versions as the drawer shows values, and long text split into the regions both sides changed.
|
|
5
|
+
* Pure. Where a choice lands in the form value is `conflict-choices.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import type { EntryConflictData } from '../api/merge-conflicts.js';
|
|
8
|
+
import type { FieldConfig } from '../config/index.js';
|
|
9
|
+
import type { FieldConflictKind } from '../merge/entry-merge.js';
|
|
10
|
+
import { type TextConflictRegion } from '../merge/text-merge.js';
|
|
11
|
+
import { type DataPathSegment, type DisplayValue, type TextDiff } from './entry-diff.js';
|
|
12
|
+
type FormValue = Record<string, unknown>;
|
|
13
|
+
interface CardBase {
|
|
14
|
+
/** Unique among the entry's cards: the conflict's key, and a region's number within it. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** The record's key, which a save's `resolvedConflicts` sends back. */
|
|
17
|
+
key: string;
|
|
18
|
+
mergeSha: string;
|
|
19
|
+
/** Human labels from the entry down to the place: "Page sections › Hero › Heading". */
|
|
20
|
+
path: string[];
|
|
21
|
+
}
|
|
22
|
+
/** A value both sides changed: a field, a whole list item, or a value the drawer shows whole. */
|
|
23
|
+
interface ValueConflictCard extends CardBase {
|
|
24
|
+
type: 'value';
|
|
25
|
+
conflict: FieldConflictKind;
|
|
26
|
+
/** A whole list item or block, which the choice keeps or removes. */
|
|
27
|
+
item: boolean;
|
|
28
|
+
before: DisplayValue;
|
|
29
|
+
latest: DisplayValue;
|
|
30
|
+
yours: DisplayValue;
|
|
31
|
+
/** Word or line marks from Before to the latest version, and from Before to yours. */
|
|
32
|
+
latestText?: TextDiff;
|
|
33
|
+
yoursText?: TextDiff;
|
|
34
|
+
/** The latest version's value here can't be found ({@link FieldConflict.latestPlaceable}). */
|
|
35
|
+
latestUnavailable?: true;
|
|
36
|
+
}
|
|
37
|
+
/** Lines of long text both sides changed, one card per region of a field. */
|
|
38
|
+
interface RegionConflictCard extends CardBase {
|
|
39
|
+
type: 'region';
|
|
40
|
+
/** 0-based, among the field's regions. */
|
|
41
|
+
index: number;
|
|
42
|
+
count: number;
|
|
43
|
+
before: string;
|
|
44
|
+
latest: string;
|
|
45
|
+
yours: string;
|
|
46
|
+
latestText?: TextDiff;
|
|
47
|
+
yoursText?: TextDiff;
|
|
48
|
+
}
|
|
49
|
+
/** The entry was moved to different places on each side; the branch's place stands. */
|
|
50
|
+
interface LocationConflictCard extends CardBase {
|
|
51
|
+
type: 'location';
|
|
52
|
+
}
|
|
53
|
+
export type ConflictCard = ValueConflictCard | RegionConflictCard | LocationConflictCard;
|
|
54
|
+
/** One recorded conflict and what its choices act on. */
|
|
55
|
+
export interface FieldConflict {
|
|
56
|
+
key: string;
|
|
57
|
+
mergeSha: string;
|
|
58
|
+
dataPath: DataPathSegment[];
|
|
59
|
+
item: boolean;
|
|
60
|
+
cards: ConflictCard[];
|
|
61
|
+
/** The latest version as a form value, which "Use latest version" takes from. */
|
|
62
|
+
latest?: FormValue;
|
|
63
|
+
/**
|
|
64
|
+
* The latest version was read and holds every list item on the place's path, other than the item
|
|
65
|
+
* a removal on its side took out, so its value there can be found and placed. It is keyed as the
|
|
66
|
+
* merge named the place along the base's own path ({@link alignedTo}), so this fails only where
|
|
67
|
+
* the record gives no such path, one that runs another way, or one whose merged key another of
|
|
68
|
+
* its items holds.
|
|
69
|
+
*/
|
|
70
|
+
latestPlaceable: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Long text found again by region from its three versions: the text the merge left, and its
|
|
73
|
+
* regions, each holding your lines there. Absent when the text is one whole value.
|
|
74
|
+
*/
|
|
75
|
+
text?: {
|
|
76
|
+
merged: string;
|
|
77
|
+
regions: TextConflictRegion[];
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export interface EntryConflicts {
|
|
81
|
+
fields: FieldConflict[];
|
|
82
|
+
/** Versions the API couldn't show, so a field's cards may show less. */
|
|
83
|
+
incomplete: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The cards for one entry's open field conflicts. `bodyField` is the schema's body field on an
|
|
87
|
+
* md/mdx entry. Each version the cards compare is diffed once per pair, however many fields share it.
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildEntryConflicts(data: EntryConflictData, fields: readonly FieldConfig[], bodyField: string | undefined, referenceTitle?: (id: string) => string | undefined, saved?: FormValue): EntryConflicts;
|
|
90
|
+
export {};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cards "Show changes" shows for an entry's open conflicts, built from what
|
|
3
|
+
* `GET /:branch/conflicts/...entryPath` answers (api/merge-conflicts.ts): each place's three
|
|
4
|
+
* versions as the drawer shows values, and long text split into the regions both sides changed.
|
|
5
|
+
* Pure. Where a choice lands in the form value is `conflict-choices.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import equal from 'fast-deep-equal';
|
|
8
|
+
import { LOCATION_CONFLICT_KEY } from '../merge/conflict-path.js';
|
|
9
|
+
import { mergeText } from '../merge/text-merge.js';
|
|
10
|
+
import { computeEntryDiff, displayWhole, textDiff, } from './entry-diff.js';
|
|
11
|
+
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
12
|
+
import { humanizeName, schemaLabel } from './schema-label.js';
|
|
13
|
+
import { formValueOfSide, valueAtChange } from './undo-change.js';
|
|
14
|
+
import { ITEM_KEY } from '../item-keys.js';
|
|
15
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
16
|
+
const EMPTY = { kind: 'empty' };
|
|
17
|
+
/** The version a blob id names; undefined when it can't be shown. */
|
|
18
|
+
const versionOf = (versions, sha) => {
|
|
19
|
+
if (sha === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
const side = versions[sha];
|
|
22
|
+
return side === undefined || 'unavailable' in side ? undefined : side;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* `version` with its list items along `from`, its own path to a conflict's place, keyed as `to`,
|
|
26
|
+
* the merged path, names them, so `to` finds the value in it: an item of a list a side left keyless
|
|
27
|
+
* reads there with a key of its own content (api/merge-conflicts.ts `EntryConflictField.sidePaths`).
|
|
28
|
+
* Unchanged where there is no `from` or it is `to`. Undefined, as a version that can't be shown,
|
|
29
|
+
* where the two paths don't run alike or a key `to` names is another item's in that list: the
|
|
30
|
+
* merged path would find that item there.
|
|
31
|
+
*/
|
|
32
|
+
const alignedTo = (version, from, to) => {
|
|
33
|
+
if (!version || !from || equal(from, to))
|
|
34
|
+
return version;
|
|
35
|
+
// A path one step short of an item names the list of a version without that item.
|
|
36
|
+
const last = to.at(-1);
|
|
37
|
+
const missing = from.length === to.length - 1 && last !== undefined && 'item' in last ? last.item : undefined;
|
|
38
|
+
if (missing === undefined && from.length !== to.length)
|
|
39
|
+
return undefined;
|
|
40
|
+
const data = structuredClone(version.data);
|
|
41
|
+
let node = data;
|
|
42
|
+
for (const [i, segment] of from.entries()) {
|
|
43
|
+
const target = to[i];
|
|
44
|
+
if ('field' in segment) {
|
|
45
|
+
if (!('field' in target) || target.field !== segment.field)
|
|
46
|
+
return undefined;
|
|
47
|
+
node = isPlainRecord(node) ? node[segment.field] : undefined;
|
|
48
|
+
}
|
|
49
|
+
else if ('blockData' in segment) {
|
|
50
|
+
if (!('blockData' in target))
|
|
51
|
+
return undefined;
|
|
52
|
+
node = isPlainRecord(node) ? (isPlainRecord(node.value) ? node.value : node) : undefined;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (!('item' in target) || !Array.isArray(node))
|
|
56
|
+
return undefined;
|
|
57
|
+
const keyed = (key) => node.find((x) => isPlainRecord(x) && x[ITEM_KEY] === key);
|
|
58
|
+
const item = keyed(segment.item);
|
|
59
|
+
if (!isPlainRecord(item))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (target.item !== segment.item) {
|
|
62
|
+
if (keyed(target.item) !== undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
item[ITEM_KEY] = target.item;
|
|
65
|
+
}
|
|
66
|
+
node = item;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (missing !== undefined) {
|
|
70
|
+
// Another item there holding the key `to` names would be found as it: it takes another key.
|
|
71
|
+
if (!Array.isArray(node))
|
|
72
|
+
return undefined;
|
|
73
|
+
const other = node.find((x) => isPlainRecord(x) && x[ITEM_KEY] === missing);
|
|
74
|
+
if (isPlainRecord(other))
|
|
75
|
+
other[ITEM_KEY] = `${missing}~other`;
|
|
76
|
+
}
|
|
77
|
+
return { ...version, data };
|
|
78
|
+
};
|
|
79
|
+
/** A diff's changes by key, a whole entry missing on a side giving none. */
|
|
80
|
+
const changesByKey = (fields, before, after, referenceTitle) => {
|
|
81
|
+
if (!before || !after)
|
|
82
|
+
return new Map();
|
|
83
|
+
const { changes } = computeEntryDiff({ fields, before, after, referenceTitle });
|
|
84
|
+
return new Map(changes.map((change) => [change.key, change]));
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* The schema field a data path ends at, and the labels of the fields on the way, for a conflict no
|
|
88
|
+
* change of the drawer names: one the merge compared whole, such as a list it had no schema for.
|
|
89
|
+
* A block's data has no one field set, so the walk stops there.
|
|
90
|
+
*/
|
|
91
|
+
const fieldAlong = (fields, dataPath) => {
|
|
92
|
+
let scope = fields;
|
|
93
|
+
let field;
|
|
94
|
+
const path = [];
|
|
95
|
+
for (const segment of dataPath) {
|
|
96
|
+
if ('field' in segment) {
|
|
97
|
+
field = scope && flattenGroupFields(scope).find((f) => f.name === segment.field);
|
|
98
|
+
path.push(field ? schemaLabel(field) : humanizeName(segment.field));
|
|
99
|
+
scope = field?.type === 'object' ? field.fields : undefined;
|
|
100
|
+
}
|
|
101
|
+
else if ('item' in segment) {
|
|
102
|
+
field = field && 'list' in field && field.list ? { ...field, list: false } : undefined;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
return { path };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { field, path };
|
|
109
|
+
};
|
|
110
|
+
/** A string at `dataPath` in a version, as the form holds it. */
|
|
111
|
+
const textAt = (version, dataPath, bodyField) => {
|
|
112
|
+
if (!version)
|
|
113
|
+
return version === null ? '' : undefined;
|
|
114
|
+
const value = valueAtChange(formValueOfSide(version), { dataPath }, bodyField);
|
|
115
|
+
return typeof value === 'string' ? value : value === undefined ? '' : undefined;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* The cards for one entry's open field conflicts. `bodyField` is the schema's body field on an
|
|
119
|
+
* md/mdx entry. Each version the cards compare is diffed once per pair, however many fields share it.
|
|
120
|
+
*/
|
|
121
|
+
export function buildEntryConflicts(data, fields, bodyField, referenceTitle, saved) {
|
|
122
|
+
const diffs = new Map();
|
|
123
|
+
const diff = (from, to) => {
|
|
124
|
+
const id = `${from ?? '-'}>${to ?? '-'}`;
|
|
125
|
+
let found = diffs.get(id);
|
|
126
|
+
if (!found) {
|
|
127
|
+
found = changesByKey(fields, versionOf(data.versions, from), versionOf(data.versions, to), referenceTitle);
|
|
128
|
+
diffs.set(id, found);
|
|
129
|
+
}
|
|
130
|
+
return found;
|
|
131
|
+
};
|
|
132
|
+
let incomplete = false;
|
|
133
|
+
const out = data.fields.map((field) => {
|
|
134
|
+
const { ancestor, base, branch } = field.sides;
|
|
135
|
+
const read = {
|
|
136
|
+
before: versionOf(data.versions, ancestor),
|
|
137
|
+
latest: versionOf(data.versions, base),
|
|
138
|
+
yours: versionOf(data.versions, branch),
|
|
139
|
+
};
|
|
140
|
+
if (Object.values(read).some((v) => v === undefined))
|
|
141
|
+
incomplete = true;
|
|
142
|
+
// Each version keyed as the merge named this place, so the one data path finds it in all three.
|
|
143
|
+
const versions = {
|
|
144
|
+
before: alignedTo(read.before, field.sidePaths.ancestor, field.dataPath),
|
|
145
|
+
latest: alignedTo(read.latest, field.sidePaths.base, field.dataPath),
|
|
146
|
+
yours: alignedTo(read.yours, field.sidePaths.branch, field.dataPath),
|
|
147
|
+
};
|
|
148
|
+
const realigned = ['before', 'latest', 'yours'].some((v) => versions[v] !== read[v]);
|
|
149
|
+
if (Object.values(versions).some((v) => v === undefined))
|
|
150
|
+
incomplete = true;
|
|
151
|
+
const latest = versions.latest ? formValueOfSide(versions.latest) : undefined;
|
|
152
|
+
const latestPlaceable = !!latest &&
|
|
153
|
+
field.dataPath.every((segment, i) => !('item' in segment) ||
|
|
154
|
+
(field.item && field.kind === 'removed-on-base' && i === field.dataPath.length - 1) ||
|
|
155
|
+
valueAtChange(latest, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !==
|
|
156
|
+
undefined);
|
|
157
|
+
const common = {
|
|
158
|
+
key: field.key,
|
|
159
|
+
mergeSha: field.mergeSha,
|
|
160
|
+
dataPath: field.dataPath,
|
|
161
|
+
item: !!field.item,
|
|
162
|
+
latestPlaceable,
|
|
163
|
+
...(latest && { latest }),
|
|
164
|
+
};
|
|
165
|
+
const card = { key: field.key, mergeSha: field.mergeSha };
|
|
166
|
+
if (field.key === LOCATION_CONFLICT_KEY) {
|
|
167
|
+
return { ...common, cards: [{ ...card, id: field.key, type: 'location', path: [] }] };
|
|
168
|
+
}
|
|
169
|
+
// A realigned version is this field's own, so its diffs are too.
|
|
170
|
+
const pair = (from, to, fromSha, toSha) => (realigned ? changesByKey(fields, from, to, referenceTitle) : diff(fromSha, toSha));
|
|
171
|
+
const toLatest = pair(versions.before, versions.latest, ancestor, base);
|
|
172
|
+
const toYours = pair(versions.before, versions.yours, ancestor, branch);
|
|
173
|
+
const between = pair(versions.latest, versions.yours, base, branch);
|
|
174
|
+
const named = toLatest.get(field.drawerKey) ?? toYours.get(field.drawerKey);
|
|
175
|
+
const along = fieldAlong(fields, field.dataPath);
|
|
176
|
+
const path = named?.path ?? between.get(field.drawerKey)?.path ?? along.path;
|
|
177
|
+
// Your saved value where a save since the merge changed it, which is what keeping yours keeps:
|
|
178
|
+
// the branch's version is only how it read at the merge.
|
|
179
|
+
const savedHere = saved && valueAtChange(saved, { dataPath: field.dataPath }, bodyField);
|
|
180
|
+
const branchHere = versions.yours &&
|
|
181
|
+
valueAtChange(formValueOfSide(versions.yours), { dataPath: field.dataPath }, bodyField);
|
|
182
|
+
const savedYours = saved && !equal(savedHere, branchHere)
|
|
183
|
+
? displayWhole(along.field, savedHere, referenceTitle)
|
|
184
|
+
: undefined;
|
|
185
|
+
const savedMarks = (before) => savedYours && before.kind === 'text' && savedYours.kind === 'text'
|
|
186
|
+
? textDiff(before.text, savedYours.text)
|
|
187
|
+
: undefined;
|
|
188
|
+
if (field.text) {
|
|
189
|
+
const texts = [versions.before, versions.latest, versions.yours].map((v) => textAt(v, field.dataPath, bodyField));
|
|
190
|
+
const [o, a, b] = texts;
|
|
191
|
+
const merged = latestPlaceable && o !== undefined && a !== undefined && b !== undefined
|
|
192
|
+
? mergeText(o, a, b)
|
|
193
|
+
: undefined;
|
|
194
|
+
// Regions place their lines in the text the merge left, so they apply only while your saved
|
|
195
|
+
// text is that text: not once you saved since, nor where the merge kept your text whole.
|
|
196
|
+
const regionsApply = !!merged &&
|
|
197
|
+
merged.regions.length > 0 &&
|
|
198
|
+
(saved === undefined ||
|
|
199
|
+
valueAtChange(saved, { dataPath: field.dataPath }, bodyField) === merged.text);
|
|
200
|
+
if (merged && regionsApply) {
|
|
201
|
+
return {
|
|
202
|
+
...common,
|
|
203
|
+
text: { merged: merged.text, regions: merged.regions },
|
|
204
|
+
cards: merged.regions.map((region, index) => ({
|
|
205
|
+
...card,
|
|
206
|
+
id: `${field.key}#${index}`,
|
|
207
|
+
type: 'region',
|
|
208
|
+
path,
|
|
209
|
+
index,
|
|
210
|
+
count: merged.regions.length,
|
|
211
|
+
before: region.ancestor,
|
|
212
|
+
latest: region.base,
|
|
213
|
+
yours: region.branch,
|
|
214
|
+
latestText: textDiff(region.ancestor, region.base, true),
|
|
215
|
+
yoursText: textDiff(region.ancestor, region.branch, true),
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const fromLatest = toLatest.get(field.drawerKey);
|
|
221
|
+
const fromYours = toYours.get(field.drawerKey);
|
|
222
|
+
const across = between.get(field.drawerKey);
|
|
223
|
+
if (!fromLatest && !fromYours && !across) {
|
|
224
|
+
// No change of the drawer is at this place: the merge compared it whole, so it shows whole.
|
|
225
|
+
const whole = (version) => version
|
|
226
|
+
? displayWhole(along.field, valueAtChange(formValueOfSide(version), { dataPath: field.dataPath }, bodyField), referenceTitle)
|
|
227
|
+
: EMPTY;
|
|
228
|
+
const [before, latestValue, yoursValue] = [
|
|
229
|
+
versions.before,
|
|
230
|
+
versions.latest,
|
|
231
|
+
versions.yours,
|
|
232
|
+
].map(whole);
|
|
233
|
+
const marks = (to) => before.kind === 'text' && to.kind === 'text' ? textDiff(before.text, to.text) : undefined;
|
|
234
|
+
const latestText = marks(latestValue);
|
|
235
|
+
const yoursText = savedYours ? savedMarks(before) : marks(yoursValue);
|
|
236
|
+
return {
|
|
237
|
+
...common,
|
|
238
|
+
cards: [
|
|
239
|
+
{
|
|
240
|
+
...card,
|
|
241
|
+
id: field.key,
|
|
242
|
+
type: 'value',
|
|
243
|
+
conflict: field.kind,
|
|
244
|
+
item: !!field.item,
|
|
245
|
+
path,
|
|
246
|
+
before,
|
|
247
|
+
latest: latestValue,
|
|
248
|
+
...(!latestPlaceable && { latestUnavailable: true }),
|
|
249
|
+
yours: savedYours ?? yoursValue,
|
|
250
|
+
...(latestText && { latestText }),
|
|
251
|
+
...(yoursText && { yoursText }),
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const before = fromLatest?.before ?? fromYours?.before ?? EMPTY;
|
|
257
|
+
const yoursText = savedYours ? savedMarks(before) : fromYours?.text;
|
|
258
|
+
return {
|
|
259
|
+
...common,
|
|
260
|
+
cards: [
|
|
261
|
+
{
|
|
262
|
+
...card,
|
|
263
|
+
id: field.key,
|
|
264
|
+
type: 'value',
|
|
265
|
+
conflict: field.kind,
|
|
266
|
+
item: !!field.item,
|
|
267
|
+
path,
|
|
268
|
+
before,
|
|
269
|
+
latest: fromLatest ? (fromLatest.after ?? EMPTY) : (across?.before ?? EMPTY),
|
|
270
|
+
...(!latestPlaceable && { latestUnavailable: true }),
|
|
271
|
+
yours: savedYours ?? (fromYours ? (fromYours.after ?? EMPTY) : (across?.after ?? EMPTY)),
|
|
272
|
+
...(fromLatest?.text && { latestText: fromLatest.text }),
|
|
273
|
+
...(yoursText && { yoursText }),
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
return { fields: out, incomplete };
|
|
279
|
+
}
|