canopycms 0.12.0 → 0.13.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 +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- 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 +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- 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 +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -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 +462 -62
- 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/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -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 +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -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 +93 -0
- package/dist/editor/conflict-cards.js +284 -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 +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- 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 +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- 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 +6 -0
- package/dist/git-manager.js +38 -3
- 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 +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -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/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- 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/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- 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 +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useRef } from 'react';
|
|
3
|
-
import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { Badge, Box, Button, Group, Loader, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
4
4
|
import { useMediaQuery, useMergedRef } from '@mantine/hooks';
|
|
5
|
-
import { IconChevronDown, IconLock } from '@tabler/icons-react';
|
|
5
|
+
import { IconChevronDown, IconLock, IconRefresh } from '@tabler/icons-react';
|
|
6
6
|
import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
|
|
7
7
|
import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
|
|
8
8
|
import { HOLD_BADGE, branchHoldMessage } from '../branch-status.js';
|
|
9
|
+
import { conflictRecordHeldAdminMessage, conflictRecordHeldMessage, conflictsHoldMessage, conflictsSubmitReason, } from '../conflict-notice.js';
|
|
10
|
+
import { ReleaseHoldButton } from './ReleaseHoldButton.js';
|
|
9
11
|
import { EDITOR_ACTIONS, UNFINISHED_SUBMIT_APPROVE_REASON, rewrittenSubmitReason } from '../copy.js';
|
|
10
12
|
import { reviewRoleOf } from '../../authorization/review-role.js';
|
|
11
13
|
import { SYSTEM_USER_ID } from '../../system-users.js';
|
|
@@ -15,13 +17,15 @@ import { TooltipDetail } from './TooltipDetail.js';
|
|
|
15
17
|
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
16
18
|
import { UnsavedMark } from './UnsavedMark.js';
|
|
17
19
|
import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
|
|
20
|
+
import { GetLatestChangesButton } from './GetLatestChangesButton.js';
|
|
21
|
+
import { GETTING_LATEST_CHANGES, LATEST_CHANGES_WAITING, latestChangesWaitingDetail, WAITING_NOTICE, WAITING_SUBMIT_REASON, } from '../base-update-wait.js';
|
|
18
22
|
import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
19
23
|
/**
|
|
20
24
|
* The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
|
|
21
25
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
22
26
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
23
27
|
*/
|
|
24
|
-
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, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
|
|
28
|
+
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, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, baseUpdate, latestWait = 'none', latestChangesWaiting = false, openConflicts = 0, conflictRecordHeld, }) {
|
|
25
29
|
const reviewRole = reviewRoleOf({
|
|
26
30
|
status: branchStatus,
|
|
27
31
|
createdBy: branchCreatedBy,
|
|
@@ -30,6 +34,12 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
30
34
|
isProtected: branchIsProtected,
|
|
31
35
|
pullRequestState: branchPullRequestState,
|
|
32
36
|
}, userContext);
|
|
37
|
+
const releaseHold = branchName ? conflictRecordHeld?.onRelease : undefined;
|
|
38
|
+
const heldMessage = !conflictRecordHeld
|
|
39
|
+
? undefined
|
|
40
|
+
: releaseHold
|
|
41
|
+
? conflictRecordHeldAdminMessage
|
|
42
|
+
: conflictRecordHeldMessage;
|
|
33
43
|
const workflow = workflowAction({
|
|
34
44
|
branchName,
|
|
35
45
|
busy,
|
|
@@ -42,6 +52,9 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
42
52
|
savingAll: !!saveAllProgress,
|
|
43
53
|
rewritten: branchHold?.kind === 'rewritten',
|
|
44
54
|
approveBlocked: branchApproveBlocked,
|
|
55
|
+
waitingForLatest: latestWait === 'waiting',
|
|
56
|
+
openConflicts,
|
|
57
|
+
heldReason: heldMessage,
|
|
45
58
|
});
|
|
46
59
|
const lock = {
|
|
47
60
|
branchStatus,
|
|
@@ -55,12 +68,19 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
55
68
|
};
|
|
56
69
|
const branchLockReason = describeBranchLock(lock);
|
|
57
70
|
const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
|
|
71
|
+
// A hold of the pull request outranks open conflicts, which only hold the next submit.
|
|
72
|
+
const conflictsHeld = openConflicts > 0 && branchStatus === 'editing';
|
|
58
73
|
const holdMessage = branchName && branchHold && status.value !== 'loading'
|
|
59
74
|
? branchHoldMessage(branchHold, {
|
|
60
75
|
role: workflow?.kind === 'review' ? 'review' : workflow?.permitted ? 'withdraw' : 'none',
|
|
61
76
|
isAdmin: isAdmin(userContext?.groups ?? []),
|
|
62
77
|
})
|
|
63
|
-
:
|
|
78
|
+
: branchName && heldMessage && status.value !== 'loading'
|
|
79
|
+
? heldMessage
|
|
80
|
+
: branchName && conflictsHeld && status.value !== 'loading'
|
|
81
|
+
? conflictsHoldMessage(openConflicts)
|
|
82
|
+
: undefined;
|
|
83
|
+
const holdCount = !branchHold && !conflictRecordHeld && conflictsHeld ? openConflicts : undefined;
|
|
64
84
|
const theme = useMantineTheme();
|
|
65
85
|
// Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
|
|
66
86
|
const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
|
|
@@ -81,6 +101,20 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
81
101
|
: status.action;
|
|
82
102
|
// Controls come and go as a submit or withdraw changes the lock, and focus follows them.
|
|
83
103
|
const [[wayRef, saveAllRef, workflowRef, secondaryRef], focusTarget] = useFocusHandOff(4);
|
|
104
|
+
// A release unmounts its confirm with the button that opened it, which leaves focus nowhere: it
|
|
105
|
+
// goes to the workflow control, which the release has just freed.
|
|
106
|
+
const releasable = !!releaseHold;
|
|
107
|
+
const wasReleasable = useRef(releasable);
|
|
108
|
+
const releasedHere = useRef(false);
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const active = document.activeElement;
|
|
111
|
+
if (wasReleasable.current && !releasable && releasedHere.current) {
|
|
112
|
+
releasedHere.current = false;
|
|
113
|
+
if (!active || active === document.body)
|
|
114
|
+
focusTarget(2)?.focus({ preventScroll: true });
|
|
115
|
+
}
|
|
116
|
+
wasReleasable.current = releasable;
|
|
117
|
+
});
|
|
84
118
|
const workflowControl = {
|
|
85
119
|
onSubmit,
|
|
86
120
|
onWithdraw,
|
|
@@ -92,14 +126,31 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
92
126
|
returnFocusTo: () => focusTarget(2),
|
|
93
127
|
};
|
|
94
128
|
const caretOnWay = narrow && !!barAction && !!workflow;
|
|
95
|
-
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
|
|
129
|
+
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,
|
|
130
|
+
// Below `md` the bar has no room for it, so the branch's menu offers it.
|
|
131
|
+
getLatest: narrow ? baseUpdate : undefined }), _jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flexShrink: 0 }, children: [_jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason, holdMessage: holdMessage, holdCount: holdCount, working: latestWait !== 'none' ? GETTING_LATEST_CHANGES : undefined,
|
|
132
|
+
// A hold's way forward comes first.
|
|
133
|
+
latestChangesWaiting: latestChangesWaiting && latestWait === 'none' && !holdMessage
|
|
134
|
+
? latestChangesWaitingDetail(narrow, !!baseUpdate?.blockedReason)
|
|
135
|
+
: undefined, compact: narrow,
|
|
136
|
+
// The bar's button carries the spinner where it shows.
|
|
137
|
+
workingSpinner: narrow || !baseUpdate }), releaseHold && (_jsx(ReleaseHoldButton, { branchLabel: branchLabel ?? branchName, onRelease: async () => {
|
|
138
|
+
// Marked while in flight: the re-read that clears the hold can land first.
|
|
139
|
+
releasedHere.current = true;
|
|
140
|
+
const outcome = await releaseHold();
|
|
141
|
+
if (!('released' in outcome))
|
|
142
|
+
releasedHere.current = false;
|
|
143
|
+
return outcome;
|
|
144
|
+
} }))] }), 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 && baseUpdate && _jsx(GetLatestChangesButton, { control: baseUpdate }), (!narrow || editable) && (_jsx(SaveAllButton
|
|
96
145
|
// Its reserved width follows this branch's counts, not the session's largest.
|
|
97
146
|
, { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
|
|
98
147
|
(!branchName
|
|
99
148
|
? 'Open a branch first.'
|
|
100
|
-
:
|
|
101
|
-
?
|
|
102
|
-
:
|
|
149
|
+
: latestWait === 'waiting'
|
|
150
|
+
? WAITING_NOTICE
|
|
151
|
+
: entryListFailed && modifiedCount > 0
|
|
152
|
+
? 'The list of entries couldn\'t be loaded, so these changes can\'t be saved yet. Choose "Refresh branch" in the branch menu to try again.'
|
|
153
|
+
: undefined), saveAllProgress: saveAllProgress, singleSaving: singleSaving, busy: busy, buttonRef: saveAllRef, caret: split && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined }, branchName)), barAction && (_jsx(BranchWayButton, { action: barAction, buttonRef: wayRef, onClick: barAction === 'newBranch'
|
|
103
154
|
? onNewBranch
|
|
104
155
|
: barAction === 'refreshBranch'
|
|
105
156
|
? onBranchReloadData
|
|
@@ -131,13 +182,18 @@ function unsavedTooltip(status, count, mayWithdraw) {
|
|
|
131
182
|
* The branch's state beside the pill, heard in words with its reason, but not while it loads. A
|
|
132
183
|
* hold follows it whatever the lock, since a lock doesn't say that the branch is stuck.
|
|
133
184
|
*/
|
|
134
|
-
function BranchStatusLabel({ status, reason, holdMessage, }) {
|
|
135
|
-
const spoken =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
185
|
+
function BranchStatusLabel({ status, reason, holdMessage, holdCount, working, workingSpinner = true, latestChangesWaiting, compact = false, }) {
|
|
186
|
+
const spoken = [
|
|
187
|
+
status.label && status.value !== 'loading'
|
|
188
|
+
? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
|
|
189
|
+
: '',
|
|
190
|
+
working,
|
|
191
|
+
// The badge sits outside this region, so a detail that follows the unsaved count stays quiet.
|
|
192
|
+
latestChangesWaiting && LATEST_CHANGES_WAITING,
|
|
193
|
+
]
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.join('. ');
|
|
196
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Box, { role: "status", style: status.label || holdMessage || working ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [working && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-working-status", children: [workingSpinner && _jsx(Loader, { size: 12 }), _jsx(Text, { span: true, size: "sm", children: working })] })), status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: holdCount === undefined ? HOLD_BADGE.label : `${HOLD_BADGE.label} (${holdCount})` }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }), latestChangesWaiting && (_jsx(TooltipDetail, { detail: latestChangesWaiting, name: compact ? LATEST_CHANGES_WAITING : undefined, children: _jsx(Badge, { color: "blue", variant: "light", circle: compact, "data-testid": "branch-latest-waiting", children: compact ? (_jsx(IconRefresh, { size: 12, style: { display: 'block' }, "aria-hidden": "true" })) : (LATEST_CHANGES_WAITING) }) }))] }));
|
|
141
197
|
}
|
|
142
198
|
const WAY_LABEL = {
|
|
143
199
|
newBranch: `${EDITOR_ACTIONS.newBranch}…`,
|
|
@@ -154,7 +210,7 @@ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
|
|
|
154
210
|
* Submit for review, or Withdraw from review once submitted or approved, or Approve and Request
|
|
155
211
|
* changes to a reviewer of someone else's submission; null when none shows.
|
|
156
212
|
*/
|
|
157
|
-
function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, }) {
|
|
213
|
+
function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, waitingForLatest, openConflicts, heldReason, }) {
|
|
158
214
|
const isSubmitted = branchStatus === 'submitted';
|
|
159
215
|
const isEditing = branchStatus === 'editing';
|
|
160
216
|
// 'approved' withdraws too -- it is the author's way back from
|
|
@@ -176,7 +232,7 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
|
|
|
176
232
|
blocked: !branchName || busy || savingAll,
|
|
177
233
|
permitted: true,
|
|
178
234
|
reason: savingAll ? 'Wait for Save all to finish.' : busy ? BUSY_REASON : '',
|
|
179
|
-
approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON :
|
|
235
|
+
approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : heldReason,
|
|
180
236
|
};
|
|
181
237
|
}
|
|
182
238
|
// Who may submit/withdraw: creator, ACL access, the system branch, or an
|
|
@@ -201,24 +257,34 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
|
|
|
201
257
|
return {
|
|
202
258
|
kind: isWithdrawable ? 'withdraw' : 'submit',
|
|
203
259
|
// `busy` alone can't hold it: an entry load clears that shared flag mid-run.
|
|
204
|
-
blocked: !branchName ||
|
|
260
|
+
blocked: !branchName ||
|
|
261
|
+
busy ||
|
|
262
|
+
savingAll ||
|
|
263
|
+
!canPerformAction ||
|
|
264
|
+
(isEditing && (rewritten || waitingForLatest || openConflicts > 0 || !!heldReason)),
|
|
205
265
|
permitted: canPerformAction,
|
|
206
266
|
reason: savingAll
|
|
207
267
|
? 'Wait for Save all to finish.'
|
|
208
|
-
:
|
|
209
|
-
?
|
|
210
|
-
:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
268
|
+
: waitingForLatest && isEditing
|
|
269
|
+
? WAITING_SUBMIT_REASON
|
|
270
|
+
: heldReason && isEditing
|
|
271
|
+
? heldReason
|
|
272
|
+
: openConflicts > 0 && isEditing
|
|
273
|
+
? conflictsSubmitReason(openConflicts)
|
|
274
|
+
: rewritten && isEditing
|
|
275
|
+
? rewrittenSubmitReason(isAdmin(userContext?.groups ?? []))
|
|
276
|
+
: // No branch data (`branchStatus === undefined`) must not be
|
|
277
|
+
// interpolated raw into this label. Guarded explicitly here rather
|
|
278
|
+
// than left to the early return above, which hides the action
|
|
279
|
+
// today only as a side effect of `branchIsProtected`'s `?? true`
|
|
280
|
+
// default -- not a guarantee this arm stays unreachable.
|
|
281
|
+
branchStatus === undefined
|
|
282
|
+
? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
|
|
283
|
+
: !statusHasAction
|
|
284
|
+
? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
|
|
285
|
+
: !userHasPermission
|
|
286
|
+
? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
|
|
287
|
+
: '',
|
|
222
288
|
};
|
|
223
289
|
}
|
|
224
290
|
function WorkflowButton({ narrow, ...props }) {
|
|
@@ -8,6 +8,9 @@ import type { BranchChangeEntry } from '../../api/branch-changes.js';
|
|
|
8
8
|
import type { FieldConfig } from '../../config/index.js';
|
|
9
9
|
import type { CanopyPathSegment } from '../canopy-path.js';
|
|
10
10
|
import type { EntryChangeState } from '../hooks/useBranchChangesData.js';
|
|
11
|
+
import type { ConflictsState } from '../hooks/useConflictsData.js';
|
|
12
|
+
import type { EntryConflictData } from '../../api/merge-conflicts.js';
|
|
13
|
+
import { type ConflictsGroupProps } from './ConflictsGroup.js';
|
|
11
14
|
/** The entry a drawer compares, as the editor knows it. */
|
|
12
15
|
export interface EntryChangesSubject {
|
|
13
16
|
logicalPath: string;
|
|
@@ -58,6 +61,15 @@ export interface EntryChangesDrawerProps {
|
|
|
58
61
|
onDeleteEntry?: () => void;
|
|
59
62
|
/** Puts back an entry deleted on the branch; resolves with why it couldn't, when it couldn't. */
|
|
60
63
|
onRestoreEntry?: () => Promise<RestoreRefusal | undefined>;
|
|
64
|
+
/** The entry's open conflicts, given while the branch lists any for it. */
|
|
65
|
+
conflicts?: EntryChangesConflicts;
|
|
66
|
+
}
|
|
67
|
+
/** What the "Conflicts" group needs beyond the drawer's own props (ConflictsGroup.tsx). */
|
|
68
|
+
export interface EntryChangesConflicts extends Pick<ConflictsGroupProps, 'adminBaseBranch' | 'choices' | 'onChoose' | 'onEdit'> {
|
|
69
|
+
state: ConflictsState<EntryConflictData | null>;
|
|
70
|
+
onResolveEntry?: ConflictsGroupProps['onResolveEntry'];
|
|
71
|
+
/** The entry has a draft, open or not, which an entry-level choice would leave behind. */
|
|
72
|
+
unsavedEdits?: boolean;
|
|
61
73
|
}
|
|
62
74
|
/** Why "Restore entry" didn't: `warning` where nothing is lost, or the outcome is unknown. */
|
|
63
75
|
export interface RestoreRefusal {
|
|
@@ -78,10 +90,12 @@ export interface EntryChangesUndo {
|
|
|
78
90
|
/** Open field threads inside a canopy path, which taking out an item there moves to the entry. */
|
|
79
91
|
threadsWithin?: (path: CanopyPathSegment[]) => number;
|
|
80
92
|
}
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
type EntryChangesViewProps = Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>;
|
|
94
|
+
/** Conflicts, what a choice settled, then the changes, which keep their place as conflicts go. */
|
|
95
|
+
export declare const EntryChangesView: React.FC<EntryChangesViewProps>;
|
|
83
96
|
/**
|
|
84
97
|
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
85
98
|
* and the form and preview wait behind its backdrop.
|
|
86
99
|
*/
|
|
87
100
|
export declare const EntryChangesDrawer: React.FC<EntryChangesDrawerProps>;
|
|
101
|
+
export {};
|
|
@@ -6,20 +6,19 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
6
6
|
* and jsdiff stay out of the editor's first chunk.
|
|
7
7
|
*/
|
|
8
8
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
-
import { Alert, Badge, Box, Button, Group,
|
|
9
|
+
import { Alert, Badge, Box, Button, Group, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
10
10
|
import { useMediaQuery } from '@mantine/hooks';
|
|
11
|
-
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil,
|
|
12
|
-
import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
|
|
13
|
-
import { useAssetContext } from '../context/index.js';
|
|
11
|
+
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconCircleCheck, IconInfoCircle, IconMinus, IconPencil, IconPlus, } from '@tabler/icons-react';
|
|
14
12
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
15
|
-
import { computeEntryDiff
|
|
16
|
-
import {
|
|
13
|
+
import { computeEntryDiff } from '../entry-diff.js';
|
|
14
|
+
import { buildEntryConflicts } from '../conflict-cards.js';
|
|
17
15
|
import { findBodyFieldName } from '../../utils/body-field.js';
|
|
18
|
-
import { formatRelativeTime } from '../relative-time.js';
|
|
19
16
|
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
20
17
|
import { movedThreadsNote } from '../undo-removal.js';
|
|
21
18
|
import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
|
|
22
19
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
20
|
+
import { DateValue, MarkedText, ReasonedButton, TEXT_STYLE, Value, ordinal, plural, } from './change-parts.js';
|
|
21
|
+
import { ConflictsGroup, SETTLED_HOLD_MS, } from './ConflictsGroup.js';
|
|
23
22
|
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
24
23
|
const KIND_BADGES = {
|
|
25
24
|
added: { label: 'Added', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
|
|
@@ -27,98 +26,6 @@ const KIND_BADGES = {
|
|
|
27
26
|
changed: { label: 'Changed', icon: _jsx(IconPencil, { size: 10, "aria-hidden": true }) },
|
|
28
27
|
moved: { label: 'Moved', icon: _jsx(IconArrowsMove, { size: 10, "aria-hidden": true }) },
|
|
29
28
|
};
|
|
30
|
-
/**
|
|
31
|
-
* Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
|
|
32
|
-
* `red.2` in light, `red.6` at 30% over the dark card.
|
|
33
|
-
*/
|
|
34
|
-
const REMOVED_STYLE = {
|
|
35
|
-
background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
|
|
36
|
-
color: 'inherit',
|
|
37
|
-
textDecoration: 'line-through',
|
|
38
|
-
textDecorationThickness: 2,
|
|
39
|
-
};
|
|
40
|
-
/** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
|
|
41
|
-
const ADDED_STYLE = {
|
|
42
|
-
background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
|
|
43
|
-
color: 'inherit',
|
|
44
|
-
textDecoration: 'underline',
|
|
45
|
-
textDecorationThickness: 2,
|
|
46
|
-
};
|
|
47
|
-
const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
|
|
48
|
-
/** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
|
|
49
|
-
const COLLAPSED_LINES = 50;
|
|
50
|
-
const LONG_TEXT_LINES = 200;
|
|
51
|
-
/** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
|
|
52
|
-
const WHOLE_LINE_SHARE = 0.6;
|
|
53
|
-
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
54
|
-
const ordinal = (n) => {
|
|
55
|
-
const tens = n % 100;
|
|
56
|
-
const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
|
|
57
|
-
return `${n}${suffix}`;
|
|
58
|
-
};
|
|
59
|
-
/**
|
|
60
|
-
* `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
|
|
61
|
-
* so a screen reader hears one removal, not one per word.
|
|
62
|
-
*/
|
|
63
|
-
const sideRuns = (parts, side) => {
|
|
64
|
-
const runs = [];
|
|
65
|
-
for (const part of parts) {
|
|
66
|
-
if (side === 'before' ? part.added : part.removed)
|
|
67
|
-
continue;
|
|
68
|
-
const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
|
|
69
|
-
const last = runs[runs.length - 1];
|
|
70
|
-
const gap = runs[runs.length - 2];
|
|
71
|
-
if (mark && last && last.mark === mark) {
|
|
72
|
-
last.value += part.value;
|
|
73
|
-
}
|
|
74
|
-
else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
|
|
75
|
-
runs.pop();
|
|
76
|
-
gap.value += last.value + part.value;
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
runs.push({ value: part.value, ...(mark && { mark }) });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return runs;
|
|
83
|
-
};
|
|
84
|
-
const WHITESPACE_MARKS = {
|
|
85
|
-
' ': { shown: '·', said: 'space' },
|
|
86
|
-
'\t': { shown: '→', said: 'tab' },
|
|
87
|
-
'\n': { shown: '⏎', said: 'line break' },
|
|
88
|
-
};
|
|
89
|
-
/** A run's text; whitespace alone shows a mark for each character, said in words. */
|
|
90
|
-
const RunText = ({ value }) => {
|
|
91
|
-
if (value === '')
|
|
92
|
-
return _jsx("em", { children: "(blank line)" });
|
|
93
|
-
if (!/^\s+$/.test(value))
|
|
94
|
-
return _jsx(_Fragment, { children: value });
|
|
95
|
-
const chars = [...value];
|
|
96
|
-
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(', ') })] }));
|
|
97
|
-
};
|
|
98
|
-
const MARK_WORDS = {
|
|
99
|
-
removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
|
|
100
|
-
added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
|
|
101
|
-
};
|
|
102
|
-
/**
|
|
103
|
-
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
104
|
-
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
105
|
-
*/
|
|
106
|
-
const MarkedText = ({ parts, side, unit, }) => {
|
|
107
|
-
const runs = sideRuns(parts, side);
|
|
108
|
-
const total = runs.reduce((n, run) => n + run.value.length, 0);
|
|
109
|
-
const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
|
|
110
|
-
const mark = side === 'before' ? 'removed' : 'added';
|
|
111
|
-
const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
|
|
112
|
-
return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
|
|
113
|
-
if (!run.mark)
|
|
114
|
-
return _jsx(React.Fragment, { children: run.value }, i);
|
|
115
|
-
const Tag = run.mark === 'removed' ? 'del' : 'ins';
|
|
116
|
-
const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
|
|
117
|
-
// Whitespace alone, or a blank line's label, has nothing for a line to cross.
|
|
118
|
-
const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
|
|
119
|
-
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));
|
|
120
|
-
})] }));
|
|
121
|
-
};
|
|
122
29
|
const Gutter = ({ sign }) => (_jsx(Text, { size: "sm", c: "dimmed", "aria-hidden": true, ta: "center", style: { userSelect: 'none' }, children: sign ?? '' }));
|
|
123
30
|
const Skipped = ({ count, style }) => (_jsx(Text, { size: "xs", c: "dimmed", py: 2, style: style, children: plural(count, 'unchanged line', 'unchanged lines') }));
|
|
124
31
|
const LINE_GRID = {
|
|
@@ -216,77 +123,11 @@ const LineRows = ({ rows }) => {
|
|
|
216
123
|
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)))] }));
|
|
217
124
|
};
|
|
218
125
|
const WordsChange = ({ text, side, }) => (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: text.parts, side: side, unit: "text" }) }));
|
|
219
|
-
const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
|
|
220
|
-
/** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
|
|
221
|
-
const Thumbnail = ({ src }) => {
|
|
222
|
-
const { baseUrl } = useAssetContext();
|
|
223
|
-
const [failed, setFailed] = useState(false);
|
|
224
|
-
if (failed) {
|
|
225
|
-
return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
226
|
-
}
|
|
227
|
-
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 } }));
|
|
228
|
-
};
|
|
229
|
-
/** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
|
|
230
|
-
const LongText = ({ text }) => {
|
|
231
|
-
const [expanded, setExpanded] = useState(false);
|
|
232
|
-
const firstNew = useRef(null);
|
|
233
|
-
useEffect(() => {
|
|
234
|
-
if (expanded)
|
|
235
|
-
firstNew.current?.focus();
|
|
236
|
-
}, [expanded]);
|
|
237
|
-
const lines = text.split('\n');
|
|
238
|
-
const long = lines.length > LONG_TEXT_LINES;
|
|
239
|
-
const collapsed = !expanded && long;
|
|
240
|
-
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"] }))] }));
|
|
241
|
-
};
|
|
242
|
-
/** A value as the form would show it: titles for references, file names for images. */
|
|
243
|
-
const Value = ({ value, alt, }) => {
|
|
244
|
-
switch (value.kind) {
|
|
245
|
-
case 'empty':
|
|
246
|
-
return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
|
|
247
|
-
case 'text':
|
|
248
|
-
return _jsx(LongText, { text: value.text });
|
|
249
|
-
case 'number':
|
|
250
|
-
return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
|
|
251
|
-
case 'boolean':
|
|
252
|
-
return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
|
|
253
|
-
case 'date':
|
|
254
|
-
return _jsx(DateValue, { value: value });
|
|
255
|
-
case 'select':
|
|
256
|
-
return _jsx(Text, { size: "sm", children: value.label });
|
|
257
|
-
case 'reference':
|
|
258
|
-
return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
|
|
259
|
-
case 'image':
|
|
260
|
-
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)] }))] })] }));
|
|
261
|
-
case 'position':
|
|
262
|
-
return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
|
|
263
|
-
case 'list':
|
|
264
|
-
return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
|
|
265
|
-
case 'fields':
|
|
266
|
-
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))) }));
|
|
267
|
-
case 'raw':
|
|
268
|
-
return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
/**
|
|
272
|
-
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
273
|
-
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
274
|
-
* may be a future event, and a past one is not hours ago.
|
|
275
|
-
*/
|
|
276
|
-
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) }))] }));
|
|
277
126
|
const SideColumn = ({ label, change, side, }) => {
|
|
278
127
|
const value = change[side] ?? { kind: 'empty' };
|
|
279
128
|
const words = change.text?.mode === 'words' ? change.text : undefined;
|
|
280
129
|
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 } }))] }));
|
|
281
130
|
};
|
|
282
|
-
/**
|
|
283
|
-
* A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
|
|
284
|
-
* why (`data-disabled` keeps the Tooltip working on focus and touch).
|
|
285
|
-
*/
|
|
286
|
-
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: () => {
|
|
287
|
-
if (!reason)
|
|
288
|
-
onClick();
|
|
289
|
-
}, "data-disabled": reason ? true : undefined, "aria-disabled": reason ? true : undefined }) }));
|
|
290
131
|
const UNDONE_IN_FORM = 'Undone in the form. Save to keep it.';
|
|
291
132
|
const ALL_UNDONE_IN_FORM = 'All changes undone in the form. Save to keep them.';
|
|
292
133
|
// Undo and redo replace what the form holds at a change with the base's or the saved value, so
|
|
@@ -332,8 +173,85 @@ const changeSummary = (changes, base) => {
|
|
|
332
173
|
];
|
|
333
174
|
return `${parts.join(', ')} since ${base}`;
|
|
334
175
|
};
|
|
335
|
-
/** The
|
|
336
|
-
|
|
176
|
+
/** The conflicts' place: loading, their failure with Retry, or the group; nothing for none. */
|
|
177
|
+
const ConflictsSection = ({ conflicts, subject, referenceTitle, undo, noEditReason, lockedReason, onEntryDone }) => {
|
|
178
|
+
const { data, error, isValidating, reload } = conflicts.state;
|
|
179
|
+
const bodyField = useMemo(() => {
|
|
180
|
+
const side = data && Object.values(data.versions).find((v) => 'body' in v && v.body !== undefined);
|
|
181
|
+
return side && 'data' in side
|
|
182
|
+
? (side.bodyFieldName ?? findBodyFieldName(subject.fields))
|
|
183
|
+
: undefined;
|
|
184
|
+
}, [data, subject.fields]);
|
|
185
|
+
// Regions apply only to the text your saved value holds, so the cards are built against it.
|
|
186
|
+
const saved = noEditReason ? undefined : undo?.saved;
|
|
187
|
+
const built = useMemo(() => data && buildEntryConflicts(data, subject.fields, bodyField, referenceTitle, saved), [data, subject.fields, bodyField, referenceTitle, saved]);
|
|
188
|
+
if (data === null)
|
|
189
|
+
return null;
|
|
190
|
+
if (error && !data) {
|
|
191
|
+
return (_jsx(Alert, { variant: "light", color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), "data-testid": "conflicts-failed", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: "Couldn\u2019t load this entry\u2019s conflicts." }), _jsx(Button, { size: "compact-sm", variant: "subtle", loading: isValidating, onClick: () => void reload(), children: "Retry" })] }) }));
|
|
192
|
+
}
|
|
193
|
+
if (!data || !built)
|
|
194
|
+
return _jsx(LoadingCards, {});
|
|
195
|
+
return (_jsx(ConflictsGroup, { data: data, conflicts: built, adminBaseBranch: conflicts.adminBaseBranch, choices: conflicts.choices, form: !noEditReason && undo?.saved
|
|
196
|
+
? { value: undo.value, saved: undo.saved, update: undo.update, bodyField }
|
|
197
|
+
: undefined, onChoose: conflicts.onChoose, onEdit: conflicts.onEdit, onResolveEntry: conflicts.onResolveEntry, onEntryDone: onEntryDone, noEditReason: noEditReason, lockedReason: lockedReason, unsavedEdits: conflicts.unsavedEdits ?? subject.unsavedEdits, entryDeleted: subject.status === 'deleted' }));
|
|
198
|
+
};
|
|
199
|
+
/** Focus once a choice's card is gone: "Restore entry" if it is offered, else the drawer's title. */
|
|
200
|
+
const focusAfterEntryChoice = (root, entryGone) => {
|
|
201
|
+
const restore = entryGone
|
|
202
|
+
? root?.querySelector('[data-testid="entry-changes-restore"]')
|
|
203
|
+
: null;
|
|
204
|
+
if (restore)
|
|
205
|
+
return restore.focus();
|
|
206
|
+
const titleId = root?.closest('[role="dialog"]')?.getAttribute('aria-labelledby');
|
|
207
|
+
const title = titleId ? document.getElementById(titleId) : null;
|
|
208
|
+
if (!title)
|
|
209
|
+
return;
|
|
210
|
+
title.tabIndex = -1;
|
|
211
|
+
title.focus();
|
|
212
|
+
};
|
|
213
|
+
/** Conflicts, what a choice settled, then the changes, which keep their place as conflicts go. */
|
|
214
|
+
export const EntryChangesView = (props) => {
|
|
215
|
+
const rootRef = useRef(null);
|
|
216
|
+
const [said, setSaid] = useState();
|
|
217
|
+
const entryDone = said?.path === props.subject.logicalPath ? said : undefined;
|
|
218
|
+
// Focus waits for the card to go: only then may "Restore entry" be offered in its place.
|
|
219
|
+
const focusAfter = useRef();
|
|
220
|
+
const onEntryDone = (done) => {
|
|
221
|
+
setSaid((last) => ({
|
|
222
|
+
id: (last?.id ?? 0) + 1,
|
|
223
|
+
message: done.message,
|
|
224
|
+
path: props.subject.logicalPath,
|
|
225
|
+
}));
|
|
226
|
+
focusAfter.current = {
|
|
227
|
+
entryGone: done.entryGone,
|
|
228
|
+
path: props.subject.logicalPath,
|
|
229
|
+
at: Date.now(),
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const root = rootRef.current;
|
|
234
|
+
const pending = focusAfter.current;
|
|
235
|
+
if (!root || !pending)
|
|
236
|
+
return;
|
|
237
|
+
// No longer than the card holds its choice: a card gone later went for another reason.
|
|
238
|
+
if (pending.path !== props.subject.logicalPath || Date.now() - pending.at > SETTLED_HOLD_MS) {
|
|
239
|
+
focusAfter.current = undefined;
|
|
240
|
+
}
|
|
241
|
+
if (!focusAfter.current || root.querySelector('[data-entry-card]'))
|
|
242
|
+
return;
|
|
243
|
+
focusAfter.current = undefined;
|
|
244
|
+
// Focus anywhere but the page or the drawer itself was put there since, and stays.
|
|
245
|
+
const dialog = root.closest('[role="dialog"]');
|
|
246
|
+
const active = document.activeElement;
|
|
247
|
+
if (active && active !== document.body && active !== dialog)
|
|
248
|
+
return;
|
|
249
|
+
focusAfterEntryChoice(root, pending.entryGone);
|
|
250
|
+
});
|
|
251
|
+
return (_jsxs(Stack, { gap: "lg", ref: rootRef, children: [props.conflicts && (_jsx(ConflictsSection, { conflicts: props.conflicts, subject: props.subject, referenceTitle: props.referenceTitle, undo: props.undo, noEditReason: props.noEditReason, lockedReason: props.lockedReason, onEntryDone: onEntryDone })), _jsxs("div", { children: [(props.conflicts || entryDone) && (_jsx(Group, { gap: 6, wrap: "nowrap", role: "status", mb: entryDone ? 'md' : 0, "data-testid": "entry-choice-done", children: entryDone && (_jsxs(React.Fragment, { children: [_jsx(IconCircleCheck, { size: 16, color: "var(--mantine-color-green-filled)", "aria-hidden": true }), _jsx(Text, { size: "sm", children: entryDone.message })] }, entryDone.id)) })), _jsx(EntryChangesBody, { ...props })] })] }));
|
|
252
|
+
};
|
|
253
|
+
/** The changes: their states, then the summary and one card per change. */
|
|
254
|
+
const EntryChangesBody = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
|
|
337
255
|
const { data, error } = state;
|
|
338
256
|
const diff = useMemo(() => data &&
|
|
339
257
|
computeEntryDiff({
|
|
@@ -69,6 +69,8 @@ export interface EntryHeaderProps {
|
|
|
69
69
|
branchPullRequestClosed?: boolean;
|
|
70
70
|
/** The server refuses Approve on this branch (`describeBranchLock`). */
|
|
71
71
|
branchApproveBlocked?: boolean;
|
|
72
|
+
/** Why content writes wait though the branch isn't locked: the latest changes coming in. */
|
|
73
|
+
writesWaitReason?: string;
|
|
72
74
|
}
|
|
73
75
|
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
74
|
-
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, branchApproveBlocked, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
76
|
+
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, branchApproveBlocked, writesWaitReason, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -28,7 +28,7 @@ const ShowChangesButton = ({ onClick, buttonRef, }) => {
|
|
|
28
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
29
|
};
|
|
30
30
|
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
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, branchApproveBlocked = 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, branchApproveBlocked = false, writesWaitReason, }) {
|
|
32
32
|
const branchLockReason = describeBranchLock({
|
|
33
33
|
branchStatus,
|
|
34
34
|
branchReadOnly,
|
|
@@ -38,7 +38,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
|
|
|
38
38
|
reviewing: branchReviewing,
|
|
39
39
|
pullRequestClosed: branchPullRequestClosed,
|
|
40
40
|
approveBlocked: branchApproveBlocked,
|
|
41
|
-
});
|
|
41
|
+
}) ?? writesWaitReason;
|
|
42
42
|
const entryLockReason = entryReadOnly ? NO_EDIT_ACCESS_REASON : undefined;
|
|
43
43
|
// The Tooltip hides it while the editor is busy.
|
|
44
44
|
const saveBlockedReason = branchLockReason ??
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Ref } from 'react';
|
|
2
|
+
import { type BaseUpdateControl } from '../base-update-wait.js';
|
|
3
|
+
/** The Tooltip's text: why it waits, why it can't act, or what it does. */
|
|
4
|
+
export declare function getLatestChangesReason(control: BaseUpdateControl): string;
|
|
5
|
+
/**
|
|
6
|
+
* "Get latest changes", or "Getting latest changes…" with a spinner while the request stands. It
|
|
7
|
+
* takes the wider label's width either way, so the bar never shifts. It can't act while it works
|
|
8
|
+
* or while something blocks it, and stays focusable (`aria-disabled`) with its Tooltip saying why.
|
|
9
|
+
* Only a block dims it: the working label is progress, read at full contrast.
|
|
10
|
+
*/
|
|
11
|
+
export declare function GetLatestChangesButton({ control, buttonRef, testId, }: {
|
|
12
|
+
control: BaseUpdateControl;
|
|
13
|
+
buttonRef?: Ref<HTMLButtonElement>;
|
|
14
|
+
testId?: string;
|
|
15
|
+
}): import("react/jsx-runtime").JSX.Element;
|