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,364 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The "Conflicts" group at the top of "Show changes": one card per place both you and someone else
|
|
5
|
+
* changed since you started the branch, showing how it read before, in the latest version and in
|
|
6
|
+
* yours, and the choices that settle it. A field's choice is an unsaved edit that the entry's next
|
|
7
|
+
* save resolves (conflict-choices.ts); an entry-level choice acts at once, through the API.
|
|
8
|
+
* Authors never see a branch name here; an admin's Tooltip names the branch the latest version
|
|
9
|
+
* comes from.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useId, useState } from 'react';
|
|
12
|
+
import { Alert, Box, Button, CopyButton, Group, Paper, SimpleGrid, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
13
|
+
import { IconAlertCircle, IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
|
|
14
|
+
import { holdsChoice, isDecided, withLatest, withRegions, withYours, } from '../conflict-choices.js';
|
|
15
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
16
|
+
import { MarkedText, ReasonedButton, TEXT_STYLE, Value, plural } from './change-parts.js';
|
|
17
|
+
import { UnsavedMark } from './UnsavedMark.js';
|
|
18
|
+
/**
|
|
19
|
+
* The three versions a card shows, each label with the Tooltip that says what it is.
|
|
20
|
+
* @internal Exported for tests.
|
|
21
|
+
*/
|
|
22
|
+
export const VERSION_LABELS = {
|
|
23
|
+
before: {
|
|
24
|
+
label: 'Before',
|
|
25
|
+
tooltip: 'How this field read when you started this branch.',
|
|
26
|
+
},
|
|
27
|
+
latest: {
|
|
28
|
+
label: 'Latest version',
|
|
29
|
+
tooltip: "How it reads now on the site's shared copy, changed since you started (by another person's approved changes or a developer).",
|
|
30
|
+
},
|
|
31
|
+
yours: {
|
|
32
|
+
label: 'Your version',
|
|
33
|
+
tooltip: 'Your change on this branch.',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The latest version's Tooltip; an admin also learns which branch it comes from.
|
|
38
|
+
* @internal Exported for tests.
|
|
39
|
+
*/
|
|
40
|
+
export const latestTooltip = (adminBaseBranch) => adminBaseBranch
|
|
41
|
+
? `${VERSION_LABELS.latest.tooltip} It comes from ${adminBaseBranch}.`
|
|
42
|
+
: VERSION_LABELS.latest.tooltip;
|
|
43
|
+
const CHOICE_NOTES = {
|
|
44
|
+
latest: 'Using the latest version. Save to keep this choice.',
|
|
45
|
+
yours: 'Keeping your version. Save to keep this choice.',
|
|
46
|
+
edited: 'Marked resolved. Save to keep this choice.',
|
|
47
|
+
regions: 'Chosen. Save to keep this choice.',
|
|
48
|
+
remove: 'Removing it. Save to keep this choice.',
|
|
49
|
+
keep: 'Keeping it. Save to keep this choice.',
|
|
50
|
+
};
|
|
51
|
+
const REGION_NOTES = {
|
|
52
|
+
latest: 'Using the latest version here.',
|
|
53
|
+
yours: 'Keeping your version here.',
|
|
54
|
+
both: 'Using both here: the latest version, then yours.',
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Why a choice can't replace what the form holds: an edit made since, which it would lose.
|
|
58
|
+
* @internal Exported for tests.
|
|
59
|
+
*/
|
|
60
|
+
export const EDITED_IN_FORM_REASON = 'You\'ve edited this in the form. Choose "Mark resolved" to keep your edit.';
|
|
61
|
+
const EDITED_IN_FORM = 'Edited in the form.';
|
|
62
|
+
/**
|
|
63
|
+
* Why an entry-level choice that rewrites the entry waits: it would replace unsaved edits.
|
|
64
|
+
* @internal Exported for tests.
|
|
65
|
+
*/
|
|
66
|
+
export const SAVE_FIRST_REASON = "Save or discard this entry's unsaved changes first.";
|
|
67
|
+
const OPEN_TO_CHOOSE = 'Open this entry to choose.';
|
|
68
|
+
/**
|
|
69
|
+
* Why a choice that takes the latest version can't: the latest version couldn't be read, its list
|
|
70
|
+
* items on the way to the place can't be matched to the ones the merge named, or the form no longer
|
|
71
|
+
* has the place. "Mark resolved" shows once the form is edited at the place.
|
|
72
|
+
* @internal Exported for tests.
|
|
73
|
+
*/
|
|
74
|
+
export const LATEST_UNPLACEABLE_REASON = 'The latest version of this can\'t be placed here. Change it in the form by hand, then choose "Mark resolved".';
|
|
75
|
+
const LATEST_UNAVAILABLE = "Can't be shown here";
|
|
76
|
+
/**
|
|
77
|
+
* A card's heading: where, and what happened there, in plain words.
|
|
78
|
+
* @internal Exported for tests.
|
|
79
|
+
*/
|
|
80
|
+
export function conflictHeading(card) {
|
|
81
|
+
if (card.type === 'location')
|
|
82
|
+
return "This entry's URL was changed by you and by someone else";
|
|
83
|
+
const where = card.path.join(' › ');
|
|
84
|
+
const part = card.type === 'region' && card.count > 1 ? ` (part ${card.index + 1} of ${card.count})` : '';
|
|
85
|
+
if (card.type === 'value' && card.conflict === 'removed-on-base') {
|
|
86
|
+
return `${where} was removed from the latest version, but you changed it`;
|
|
87
|
+
}
|
|
88
|
+
if (card.type === 'value' && card.conflict === 'removed-by-you') {
|
|
89
|
+
return `You removed ${where}, but it was changed in the latest version`;
|
|
90
|
+
}
|
|
91
|
+
return `${where}${part} was changed by you and by someone else`;
|
|
92
|
+
}
|
|
93
|
+
/** What an entry-level conflict is, and the choices that settle it, in order. */
|
|
94
|
+
const ENTRY_KINDS = {
|
|
95
|
+
'deleted-on-base': {
|
|
96
|
+
heading: 'This entry was deleted in the latest version, but you changed it',
|
|
97
|
+
choices: [
|
|
98
|
+
{ choice: 'keep', label: EDITOR_ACTIONS.keepIt },
|
|
99
|
+
{ choice: 'delete', label: EDITOR_ACTIONS.deleteEntry, danger: true },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
'deleted-on-branch': {
|
|
103
|
+
heading: 'You deleted this entry, but it was changed in the latest version',
|
|
104
|
+
choices: [
|
|
105
|
+
{ choice: 'restore', label: EDITOR_ACTIONS.restoreEntry },
|
|
106
|
+
{ choice: 'keep', label: EDITOR_ACTIONS.keepItDeleted },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
// Keeping it deleted clears the record: `keep` resolves a `fields` record whole (api/merge-conflicts.ts).
|
|
110
|
+
'fields-on-deleted': {
|
|
111
|
+
heading: 'You deleted this entry, but fields in it were changed by you and by someone else',
|
|
112
|
+
body: `Keeping it deleted drops the latest version's changes to it. You can still use "${EDITOR_ACTIONS.restoreEntry}" afterwards, to bring it back as the latest version has it.`,
|
|
113
|
+
choices: [{ choice: 'keep', label: EDITOR_ACTIONS.keepItDeleted }],
|
|
114
|
+
},
|
|
115
|
+
unreadable: {
|
|
116
|
+
heading: "This entry was changed by you and by someone else in a way that can't be compared field by field",
|
|
117
|
+
choices: [
|
|
118
|
+
{ choice: 'use-latest', label: EDITOR_ACTIONS.useLatestVersion },
|
|
119
|
+
{ choice: 'keep', label: EDITOR_ACTIONS.keepYourVersion },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
/** The text a value can be copied as; undefined for one with no plain text, such as an image. */
|
|
124
|
+
const copyTextOf = (value) => {
|
|
125
|
+
switch (value.kind) {
|
|
126
|
+
case 'text':
|
|
127
|
+
case 'raw':
|
|
128
|
+
return value.text;
|
|
129
|
+
case 'date':
|
|
130
|
+
return value.text;
|
|
131
|
+
case 'number':
|
|
132
|
+
return String(value.value);
|
|
133
|
+
case 'select':
|
|
134
|
+
return value.label;
|
|
135
|
+
default:
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
/** A version's label, its Tooltip opening on hover, focus and touch, and said to a screen reader. */
|
|
140
|
+
const VersionLabel = ({ label, tooltip }) => {
|
|
141
|
+
const id = useId();
|
|
142
|
+
return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: tooltip, multiline: true, maw: 280, events: { hover: true, focus: true, touch: true }, children: _jsxs(UnstyledButton, { "aria-describedby": id, style: { display: 'inline-flex', alignItems: 'center', gap: 4 }, "data-testid": "conflict-version-label", children: [_jsx(Text, { size: "xs", c: "dimmed", span: true, children: label }), _jsx(IconInfoCircle, { size: 12, "aria-hidden": true, style: { color: 'var(--mantine-color-dimmed)' } })] }) }), _jsx(VisuallyHidden, { id: id, children: tooltip })] }));
|
|
143
|
+
};
|
|
144
|
+
/** One side of a line diff: its lines, the changed runs marked. */
|
|
145
|
+
const SideLines = ({ text }) => (_jsx(Stack, { gap: 0, children: text.rows.map((row, i) => {
|
|
146
|
+
if (row.kind === 'skipped') {
|
|
147
|
+
return (_jsx(Text, { size: "xs", c: "dimmed", children: plural(row.count, 'unchanged line', 'unchanged lines') }, i));
|
|
148
|
+
}
|
|
149
|
+
if (row.kind === 'same') {
|
|
150
|
+
return (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: row.text || ' ' }, i));
|
|
151
|
+
}
|
|
152
|
+
if (!row.after)
|
|
153
|
+
return null;
|
|
154
|
+
return (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: row.after, side: "after", unit: "line" }) }, i));
|
|
155
|
+
}) }));
|
|
156
|
+
/** A version's value, marked against Before where it changed text. */
|
|
157
|
+
const VersionValue = ({ value, marks, unavailable }) => {
|
|
158
|
+
if (unavailable) {
|
|
159
|
+
return (_jsx(Text, { size: "sm", c: "dimmed", "data-testid": "conflict-latest-unavailable", children: LATEST_UNAVAILABLE }));
|
|
160
|
+
}
|
|
161
|
+
if (marks?.mode === 'words') {
|
|
162
|
+
return (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: marks.parts, side: "after", unit: "text" }) }));
|
|
163
|
+
}
|
|
164
|
+
if (marks?.mode === 'lines')
|
|
165
|
+
return _jsx(SideLines, { text: marks });
|
|
166
|
+
if (typeof value === 'string') {
|
|
167
|
+
return value === '' ? (_jsx(Text, { size: "sm", c: "dimmed", children: "No lines" })) : (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: value }));
|
|
168
|
+
}
|
|
169
|
+
return _jsx(Value, { value: value });
|
|
170
|
+
};
|
|
171
|
+
const Versions = ({ card, adminBaseBranch }) => (_jsxs(SimpleGrid, { cols: { base: 1, md: 3 }, spacing: "sm", children: [_jsxs(Stack, { gap: 4, miw: 0, "data-testid": "conflict-before", children: [_jsx(VersionLabel, { ...VERSION_LABELS.before }), _jsx(VersionValue, { value: card.before })] }), _jsxs(Stack, { gap: 4, miw: 0, "data-testid": "conflict-latest", children: [_jsx(VersionLabel, { label: VERSION_LABELS.latest.label, tooltip: latestTooltip(adminBaseBranch) }), _jsx(VersionValue, { value: card.latest, marks: card.latestText, unavailable: card.type === 'value' && card.latestUnavailable })] }), _jsxs(Stack, { gap: 4, miw: 0, "data-testid": "conflict-yours", children: [_jsx(VersionLabel, { ...VERSION_LABELS.yours }), _jsx(VersionValue, { value: card.yours, marks: card.yoursText })] })] }));
|
|
172
|
+
const ChoiceNote = ({ text }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", "data-testid": "conflict-choice-note", children: [_jsx(UnsavedMark, { "aria-hidden": "true" }), _jsx(Text, { size: "xs", children: text })] }));
|
|
173
|
+
/** Latest, then yours, as "Use both" would place them, shown before it is applied. */
|
|
174
|
+
const UseBothPreview = ({ latest, yours }) => {
|
|
175
|
+
const [shown, setShown] = useState(false);
|
|
176
|
+
const id = useId();
|
|
177
|
+
return (_jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Button, { size: "compact-xs", variant: "subtle", "aria-expanded": shown, "aria-controls": shown ? id : undefined, onClick: () => setShown((s) => !s), children: shown ? 'Hide "Use both" preview' : 'Preview "Use both"' }), shown && (_jsxs(Box, { id: id, "data-testid": "conflict-use-both-preview", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Use both: latest, then yours" }), _jsx(Text, { size: "sm", style: TEXT_STYLE, children: [latest, yours].filter((t) => t !== '').join('\n') })] }))] }));
|
|
178
|
+
};
|
|
179
|
+
const FieldCardActions = ({ card, actions, label, }) => {
|
|
180
|
+
const { reason, latestReason, choice, held } = actions;
|
|
181
|
+
const named = (verb) => `${verb}: ${label}`;
|
|
182
|
+
const btn = (verb, onClick, testId, why = reason) => (_jsx(ReasonedButton, { reason: why, onClick: onClick, "aria-label": named(verb), "data-testid": testId, children: verb }, testId));
|
|
183
|
+
const editedReason = reason ?? (held ? undefined : EDITED_IN_FORM_REASON);
|
|
184
|
+
// A choice taking the latest version waits for both: nothing to lose, and something to take.
|
|
185
|
+
const latestTakes = reason ?? latestReason ?? editedReason;
|
|
186
|
+
if (card.type === 'region') {
|
|
187
|
+
const chosen = choice?.how === 'regions' ? choice.regions[card.index] : undefined;
|
|
188
|
+
const fieldChosen = choice && choice.how !== 'regions';
|
|
189
|
+
if (chosen || fieldChosen) {
|
|
190
|
+
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [_jsx(ChoiceNote, { text: chosen
|
|
191
|
+
? `${REGION_NOTES[chosen]} Save to keep this choice.`
|
|
192
|
+
: CHOICE_NOTES[choice?.how ?? 'edited'] }), btn(EDITOR_ACTIONS.chooseAgain, () => (chosen ? actions.clearRegion(card.index) : actions.choose(undefined)), 'conflict-choose-again')] }));
|
|
193
|
+
}
|
|
194
|
+
return (_jsxs(Stack, { gap: 4, children: [_jsx(UseBothPreview, { latest: card.latest, yours: card.yours }), _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [btn(EDITOR_ACTIONS.useLatestVersion, () => actions.chooseRegion(card.index, 'latest'), 'conflict-use-latest', latestTakes), held
|
|
195
|
+
? btn(EDITOR_ACTIONS.keepYourVersion, () => actions.chooseRegion(card.index, 'yours'), 'conflict-keep-yours')
|
|
196
|
+
: btn(EDITOR_ACTIONS.markResolved, () => actions.choose({ how: 'edited' }), 'conflict-mark-resolved'), btn(EDITOR_ACTIONS.useBoth, () => actions.chooseRegion(card.index, 'both'), 'conflict-use-both', latestTakes), btn('Edit', actions.edit, 'conflict-edit'), !held && !reason && _jsx(Text, { size: "xs", children: EDITED_IN_FORM })] })] }));
|
|
197
|
+
}
|
|
198
|
+
if (card.type === 'location') {
|
|
199
|
+
if (choice) {
|
|
200
|
+
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [_jsx(ChoiceNote, { text: CHOICE_NOTES.yours }), btn(EDITOR_ACTIONS.chooseAgain, () => actions.choose(undefined), 'conflict-choose-again')] }));
|
|
201
|
+
}
|
|
202
|
+
return (_jsx(Group, { gap: "xs", wrap: "wrap", align: "center", children: btn(EDITOR_ACTIONS.keepYourVersion, () => actions.choose({ how: 'yours' }), 'conflict-keep-yours') }));
|
|
203
|
+
}
|
|
204
|
+
const removed = card.conflict !== 'changed';
|
|
205
|
+
if (choice) {
|
|
206
|
+
// "Remove it" and "Keep it" name what happens to the value, not whose version it is.
|
|
207
|
+
const note = !removed
|
|
208
|
+
? CHOICE_NOTES[choice.how]
|
|
209
|
+
: (choice.how === 'latest') === (card.conflict === 'removed-on-base')
|
|
210
|
+
? CHOICE_NOTES.remove
|
|
211
|
+
: CHOICE_NOTES.keep;
|
|
212
|
+
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [_jsx(ChoiceNote, { text: note }), btn(EDITOR_ACTIONS.chooseAgain, () => actions.choose(undefined), 'conflict-choose-again')] }));
|
|
213
|
+
}
|
|
214
|
+
if (removed) {
|
|
215
|
+
const removeIs = card.conflict === 'removed-on-base' ? 'latest' : 'yours';
|
|
216
|
+
const keepIs = removeIs === 'latest' ? 'yours' : 'latest';
|
|
217
|
+
const pick = (how) => () => actions.choose(how === 'latest' ? { how: 'latest' } : { how: 'yours' });
|
|
218
|
+
const copy = copyTextOf(card.yours);
|
|
219
|
+
// Your side is what the form holds, so once it was edited by hand the edit is what's kept.
|
|
220
|
+
const side = (verb, how, testId) => how === 'latest'
|
|
221
|
+
? btn(verb, pick(how), testId, latestTakes)
|
|
222
|
+
: held
|
|
223
|
+
? btn(verb, pick(how), testId)
|
|
224
|
+
: null;
|
|
225
|
+
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [side(EDITOR_ACTIONS.removeIt, removeIs, 'conflict-remove-it'), side(EDITOR_ACTIONS.keepIt, keepIs, 'conflict-keep-it'), !held &&
|
|
226
|
+
btn(EDITOR_ACTIONS.markResolved, () => actions.choose({ how: 'edited' }), 'conflict-mark-resolved'), copy !== undefined && (_jsx(CopyButton, { value: copy, children: ({ copied, copy: doCopy }) => (_jsx(Button, { size: "compact-sm", variant: "subtle", onClick: doCopy, "aria-label": copied ? `Copied: ${label}` : `Copy your version: ${label}`, "data-testid": "conflict-copy", children: copied ? 'Copied' : 'Copy' })) }))] }));
|
|
227
|
+
}
|
|
228
|
+
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [btn(EDITOR_ACTIONS.useLatestVersion, () => actions.choose({ how: 'latest' }), 'conflict-use-latest', latestTakes), held
|
|
229
|
+
? btn(EDITOR_ACTIONS.keepYourVersion, () => actions.choose({ how: 'yours' }), 'conflict-keep-yours')
|
|
230
|
+
: btn(EDITOR_ACTIONS.markResolved, () => actions.choose({ how: 'edited' }), 'conflict-mark-resolved'), !card.item && btn('Edit', actions.edit, 'conflict-edit'), !held && !reason && _jsx(Text, { size: "xs", children: EDITED_IN_FORM })] }));
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* A conflict card's yellow mark (a 409 conflict is yellow: nothing is lost yet) beside its warning
|
|
234
|
+
* icon and heading, never colour alone. The card keeps the drawer's surface, which the text and
|
|
235
|
+
* change marks are measured on.
|
|
236
|
+
*/
|
|
237
|
+
const CARD_ACCENT = {
|
|
238
|
+
borderInlineStart: '4px solid var(--mantine-color-yellow-filled)',
|
|
239
|
+
};
|
|
240
|
+
const FieldCard = ({ card, adminBaseBranch, actions }) => {
|
|
241
|
+
const heading = conflictHeading(card);
|
|
242
|
+
return (_jsx(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", style: CARD_ACCENT, "data-testid": "conflict-card", "data-conflict-kind": card.type === 'value' ? (card.item ? 'item' : card.conflict) : card.type, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(IconAlertTriangle, { size: 16, "aria-hidden": true, style: { flexShrink: 0, marginTop: 2 } }), _jsx(Text, { component: "h4", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: heading })] }), card.type === 'location' ? (_jsxs(Text, { size: "sm", children: ["It keeps your URL. To use another, choose \"", EDITOR_ACTIONS.changeUrl, "\u2026\" in the entry's menu."] })) : (_jsx(Versions, { card: card, adminBaseBranch: adminBaseBranch })), actions && _jsx(FieldCardActions, { card: card, actions: actions, label: heading })] }) }));
|
|
243
|
+
};
|
|
244
|
+
const EntryCard = ({ kind, reason, unsavedEdits, onResolve }) => {
|
|
245
|
+
const [working, setWorking] = useState();
|
|
246
|
+
const [refusal, setRefusal] = useState();
|
|
247
|
+
const { heading, body, choices } = ENTRY_KINDS[kind];
|
|
248
|
+
const resolve = async (choice) => {
|
|
249
|
+
if (!onResolve || working)
|
|
250
|
+
return;
|
|
251
|
+
setWorking(choice);
|
|
252
|
+
setRefusal(undefined);
|
|
253
|
+
try {
|
|
254
|
+
const refused = await onResolve(choice);
|
|
255
|
+
setRefusal(refused && { ...refused, choice });
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
setWorking(undefined);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
return (_jsx(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", style: CARD_ACCENT, "data-testid": "conflict-card", "data-conflict-kind": kind, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(IconAlertTriangle, { size: 16, "aria-hidden": true, style: { flexShrink: 0, marginTop: 2 } }), _jsx(Text, { component: "h4", size: "sm", fw: 500, m: 0, children: heading })] }), body && (_jsx(Text, { size: "sm", "data-testid": "conflict-entry-body", children: body })), onResolve && (_jsx(Group, { gap: "xs", wrap: "wrap", align: "center", children: choices.map(({ choice, label, danger }) => (_jsx(ReasonedButton, { reason: reason ?? (choice !== 'keep' && unsavedEdits ? SAVE_FIRST_REASON : undefined), onClick: () => void resolve(choice), loading: working === choice, danger: danger, "data-testid": `conflict-entry-${choice}`, children: label }, choice))) })), refusal && (_jsx(Alert, { variant: "light", color: refusal.tone === 'error' ? 'red' : 'yellow', icon: refusal.tone === 'error' ? (_jsx(IconAlertCircle, { size: 16 })) : (_jsx(IconAlertTriangle, { size: 16 })), "data-testid": "conflict-entry-refused", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: refusal.message }), refusal.tone === 'error' && (_jsx(Button, { size: "compact-sm", variant: "subtle", loading: working === refusal.choice, onClick: () => void resolve(refusal.choice), children: "Retry" }))] }) }))] }) }));
|
|
262
|
+
};
|
|
263
|
+
/** The "Conflicts" group: a heading, a count heard as it changes, and the cards. */
|
|
264
|
+
export const ConflictsGroup = ({ data, conflicts, adminBaseBranch, choices, form, onChoose, onEdit, onResolveEntry, noEditReason, lockedReason, unsavedEdits, entryDeleted, }) => {
|
|
265
|
+
const headingId = useId();
|
|
266
|
+
const editable = !noEditReason;
|
|
267
|
+
const entryKind = data.kind === 'fields' ? undefined : data.kind;
|
|
268
|
+
// Field conflicts on an entry the branch deleted: one card settles them, the fields shown below it.
|
|
269
|
+
const deletedFields = !entryKind && !!entryDeleted;
|
|
270
|
+
// A stored choice stands while the form still holds what it chose. One the form was edited away
|
|
271
|
+
// from since stands for the edit: a decided choice becomes "Mark resolved", which the save
|
|
272
|
+
// resolves as it would the choice, open drawer or not; an undecided region choice, or one on text
|
|
273
|
+
// that now shows whole, is dropped. A locked form shows the saved value over the draft it keeps,
|
|
274
|
+
// so while locked every choice stands as made.
|
|
275
|
+
const standing = new Map();
|
|
276
|
+
const stale = [];
|
|
277
|
+
if (form) {
|
|
278
|
+
for (const field of conflicts.fields) {
|
|
279
|
+
const chosen = choices[field.key];
|
|
280
|
+
if (!chosen || chosen.mergeSha !== field.mergeSha)
|
|
281
|
+
continue;
|
|
282
|
+
const placed = chosen.choice.how !== 'regions' || !!field.text;
|
|
283
|
+
const held = !!lockedReason ||
|
|
284
|
+
chosen.choice.how === 'edited' ||
|
|
285
|
+
holdsChoice(form.value, form.saved, field, chosen.choice, form.bodyField);
|
|
286
|
+
if (placed && held) {
|
|
287
|
+
standing.set(field.key, chosen.choice);
|
|
288
|
+
}
|
|
289
|
+
else if (placed && isDecided(field, chosen.choice)) {
|
|
290
|
+
standing.set(field.key, { how: 'edited' });
|
|
291
|
+
stale.push({ field, next: { how: 'edited' } });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
stale.push({ field, next: undefined });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const staleKeys = stale.map(({ field, next }) => `${field.key}:${next?.how ?? ''}`).join(',');
|
|
299
|
+
const open = entryKind || deletedFields
|
|
300
|
+
? 1
|
|
301
|
+
: conflicts.fields.filter((f) => !isDecided(f, form ? standing.get(f.key) : choices[f.key]?.choice)).length;
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
for (const { field, next } of stale)
|
|
304
|
+
onChoose?.(field, next, !!next);
|
|
305
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `stale` is this render's, keyed by `staleKeys`
|
|
306
|
+
}, [staleKeys]);
|
|
307
|
+
const actionsFor = (field) => {
|
|
308
|
+
if (!form || !editable || !onChoose)
|
|
309
|
+
return undefined;
|
|
310
|
+
const choice = standing.get(field.key);
|
|
311
|
+
const held = holdsChoice(form.value, form.saved, field, choice, form.bodyField);
|
|
312
|
+
const record = (next) => onChoose(field, next, isDecided(field, next));
|
|
313
|
+
const latestReason = withLatest(form.value, field, form.bodyField) !== undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: LATEST_UNPLACEABLE_REASON;
|
|
316
|
+
return {
|
|
317
|
+
reason: lockedReason,
|
|
318
|
+
latestReason,
|
|
319
|
+
choice,
|
|
320
|
+
held,
|
|
321
|
+
choose: (next) => {
|
|
322
|
+
if (next?.how === 'latest') {
|
|
323
|
+
form.update((v) => withLatest(v, field, form.bodyField) ?? v);
|
|
324
|
+
}
|
|
325
|
+
else if (next === undefined &&
|
|
326
|
+
held &&
|
|
327
|
+
(choice?.how === 'latest' || choice?.how === 'regions')) {
|
|
328
|
+
// Choosing again takes back what a choice wrote, unless the form was edited since; an
|
|
329
|
+
// edit marked resolved stays.
|
|
330
|
+
form.update((v) => withYours(v, form.saved, field, form.bodyField) ?? v);
|
|
331
|
+
}
|
|
332
|
+
record(next);
|
|
333
|
+
},
|
|
334
|
+
chooseRegion: (region, regionChoice) => {
|
|
335
|
+
const regions = {
|
|
336
|
+
...(choice?.how === 'regions' ? choice.regions : {}),
|
|
337
|
+
[region]: regionChoice,
|
|
338
|
+
};
|
|
339
|
+
form.update((v) => withRegions(v, field, regions, form.bodyField) ?? v);
|
|
340
|
+
record({ how: 'regions', regions });
|
|
341
|
+
// A join almost always needs tidying, so it opens for editing.
|
|
342
|
+
if (regionChoice === 'both')
|
|
343
|
+
onEdit?.(field);
|
|
344
|
+
},
|
|
345
|
+
clearRegion: (region) => {
|
|
346
|
+
if (choice?.how !== 'regions')
|
|
347
|
+
return;
|
|
348
|
+
const { [region]: _cleared, ...regions } = choice.regions;
|
|
349
|
+
if (held)
|
|
350
|
+
form.update((v) => withRegions(v, field, regions, form.bodyField) ?? v);
|
|
351
|
+
record(Object.keys(regions).length > 0 ? { how: 'regions', regions } : undefined);
|
|
352
|
+
},
|
|
353
|
+
edit: () => onEdit?.(field),
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
return (_jsxs(Stack, { component: "section", gap: "xs", "aria-labelledby": headingId, "data-testid": "conflicts-group", children: [_jsx(Text, { component: "h3", id: headingId, size: "sm", fw: 600, m: 0, children: "Conflicts" }), _jsx(Text, { size: "sm", "data-testid": "conflicts-summary", children: entryKind || deletedFields
|
|
357
|
+
? 'Changed by you and by someone else. Choose what to keep.'
|
|
358
|
+
: `${plural(conflicts.fields.length, 'field', 'fields')} changed by you and by someone else. Choose what to keep, then save.` }), _jsx(VisuallyHidden, { role: "status", "data-testid": "conflicts-left", children: open === 0
|
|
359
|
+
? 'All chosen. Save to keep your choices.'
|
|
360
|
+
: `${plural(open, 'field', 'fields')} left to choose.` }), conflicts.incomplete && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "conflicts-incomplete", children: "Some versions couldn't be read, so some values may not show." })), noEditReason && (_jsx(Text, { size: "sm", "data-testid": "conflicts-no-edit", children: noEditReason })), editable && !form && !entryKind && !deletedFields && (_jsx(Text, { size: "sm", "data-testid": "conflicts-open-to-choose", children: OPEN_TO_CHOOSE })), editable && lockedReason && (_jsx(Text, { size: "sm", "data-testid": "conflicts-locked", children: lockedReason })), _jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: entryKind ? (_jsx(EntryCard, { kind: entryKind, reason: lockedReason, unsavedEdits: unsavedEdits, onResolve: editable ? onResolveEntry : undefined })) : (_jsxs(_Fragment, { children: [deletedFields && (_jsx(EntryCard, { kind: "fields-on-deleted", reason: lockedReason, onResolve: editable ? onResolveEntry : undefined })), conflicts.fields.flatMap((field) => {
|
|
361
|
+
const actions = actionsFor(field);
|
|
362
|
+
return field.cards.map((card) => (_jsx(FieldCard, { card: card, adminBaseBranch: adminBaseBranch, actions: actions }, card.id)));
|
|
363
|
+
})] })) })] }));
|
|
364
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Ref } from 'react';
|
|
2
2
|
import type { BranchHold, BranchStatus, PullRequestState } from '../../types.js';
|
|
3
3
|
import type { SaveAllProgress } from '../hooks/save-all.js';
|
|
4
|
+
import { type ReleaseOutcome } from './ReleaseHoldButton.js';
|
|
5
|
+
import { type BaseUpdateControl, type BaseUpdateWait } from '../base-update-wait.js';
|
|
4
6
|
export interface EditorHeaderProps {
|
|
5
7
|
branchName: string;
|
|
6
8
|
/** What the pill shows for the branch, when it is not `branchName`. */
|
|
@@ -72,10 +74,23 @@ export interface EditorHeaderProps {
|
|
|
72
74
|
branchPending?: boolean;
|
|
73
75
|
/** The branch list answered without this branch. */
|
|
74
76
|
branchMissing?: boolean;
|
|
77
|
+
/** "Get latest changes", when this user may ask on the open branch (hooks/useBaseUpdate.tsx). */
|
|
78
|
+
baseUpdate?: BaseUpdateControl;
|
|
79
|
+
/** Choices a merge's conflicts leave open on the branch, which hold Submit for review. */
|
|
80
|
+
openConflicts?: number;
|
|
81
|
+
/**
|
|
82
|
+
* The branch is held as its record of conflicts was set aside; `onRelease`, an admin's way out,
|
|
83
|
+
* releases it.
|
|
84
|
+
*/
|
|
85
|
+
conflictRecordHeld?: {
|
|
86
|
+
onRelease?: () => Promise<ReleaseOutcome>;
|
|
87
|
+
};
|
|
88
|
+
/** The open branch's wait for the latest changes, whoever asked: every viewer who sees it waits. */
|
|
89
|
+
latestWait?: BaseUpdateWait;
|
|
75
90
|
}
|
|
76
91
|
/**
|
|
77
92
|
* The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
|
|
78
93
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
79
94
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
80
95
|
*/
|
|
81
|
-
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, branchHold, branchApproveBlocked, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
96
|
+
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, branchHold, branchApproveBlocked, branchWriteBlocked, branchLoading, branchPending, branchMissing, baseUpdate, latestWait, openConflicts, conflictRecordHeld, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|