canopycms 0.11.0 → 0.12.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.
@@ -56,6 +56,10 @@ export class ContentStoreError extends Error {
56
56
  this.code = code;
57
57
  }
58
58
  }
59
+ /** Why a restore refuses: another entry took the slug after this one was deleted. */
60
+ export const RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
61
+ /** Why a restore refuses: the entry, by its content ID, is on the branch already. */
62
+ export const RESTORE_ID_TAKEN_MESSAGE = 'This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.';
59
63
  /**
60
64
  * Thrown when `expectedVersion` on a write doesn't match the file's current mtime.
61
65
  * Indicates a cross-process concurrent write — the caller should reload and retry.
@@ -1283,6 +1287,75 @@ export class ContentStore {
1283
1287
  throw new ContentConflictError();
1284
1288
  });
1285
1289
  }
1290
+ /**
1291
+ * Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
1292
+ * under its old slug, entry type and content ID, so references and comment threads find it
1293
+ * again and the branch no longer differs from the base there.
1294
+ *
1295
+ * A create under write()'s locks and create-only guards that never replaces or relocates
1296
+ * anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
1297
+ * or the target directory holds the ID: unlike write(), it has no index repair, so an ID
1298
+ * found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
1299
+ * warmed outside the lock, as write() warms it, and rescanned under the lock only when the
1300
+ * generation marker says another host changed the tree.
1301
+ */
1302
+ async restore(collectionPath, slug, entry, options = {}) {
1303
+ await this.idIndex();
1304
+ const collection = this.assertCollection(collectionPath);
1305
+ if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
1306
+ throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, 'NO_SCHEMA_ITEM');
1307
+ }
1308
+ this.assertEntryTypeAvailable(collection, entry.entryTypeName);
1309
+ return this.withContentWriteExclusion(async () => {
1310
+ await options.beforeWrite?.();
1311
+ // Every content write and every git or sync change to the tree bumps the marker
1312
+ // (content-index-generation.ts), so a marker that still matches the index's means the
1313
+ // index is current. Read here unthrottled, unlike idIndex()'s own probe; a rescan holds no
1314
+ // entry lock, which it must not.
1315
+ if ((await readContentIndexGeneration(this.root)) !== this.loadedDiskGeneration) {
1316
+ this.invalidateIndex();
1317
+ }
1318
+ await this.idIndex();
1319
+ return withLock(this.createLockKey(collection, slug), async () => {
1320
+ // The ID first: an entry already back at its own slug is this one, not another.
1321
+ const target = await this.buildPaths(collection, slug, {
1322
+ existingId: entry.id,
1323
+ entryTypeName: entry.entryTypeName,
1324
+ });
1325
+ const dir = path.dirname(target.absolutePath);
1326
+ const liveIndex = this._idIndex;
1327
+ if (liveIndex.findById(entry.id) ||
1328
+ liveIndex.getDuplicateFor(entry.id) ||
1329
+ (await this.findEntryPathsById(dir, entry.id)).length > 0) {
1330
+ throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
1331
+ }
1332
+ if ((await this.buildPaths(collection, slug)).existed) {
1333
+ throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
1334
+ }
1335
+ // [SLUG] and [URL], as write() checks a create.
1336
+ const routable = parseSlug(target.slug);
1337
+ if (!routable.ok) {
1338
+ throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, 'VALIDATION');
1339
+ }
1340
+ await this.assertUrlPathAvailable(dir, target.slug);
1341
+ await fs.mkdir(dir, { recursive: true });
1342
+ await atomicWriteFile(target.absolutePath, entry.raw);
1343
+ // The live index, looked up and changed with no await between: a rebuild since the check
1344
+ // above may have swapped in one that already scanned the restored file.
1345
+ const index = this._idIndex;
1346
+ if (!index.findById(entry.id)) {
1347
+ index.add({
1348
+ type: 'entry',
1349
+ relativePath: target.relativePath,
1350
+ collection: collectionPath,
1351
+ slug,
1352
+ });
1353
+ }
1354
+ await this.recordOwnMutation(index);
1355
+ return { relativePath: target.relativePath };
1356
+ });
1357
+ });
1358
+ }
1286
1359
  /**
1287
1360
  * Rename an entry by changing its slug (middle segment of filename).
1288
1361
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
@@ -38,6 +38,7 @@ import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
38
38
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
39
39
  import { usePreviewMarks } from './hooks/usePreviewMarks.js';
40
40
  import { branchBarStatus, describeBranchLock } from './branch-lock.js';
41
+ import { NO_EDIT_ACCESS_REASON } from './copy.js';
41
42
  import { EditorHeader, EditorRail, EntryHeader } from './components/index.js';
42
43
  import { showSaveAllResult } from './components/SaveAllResult.js';
43
44
  import { RenameEntryModal } from './components/RenameEntryModal.js';
@@ -67,7 +68,20 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
67
68
  import { PreviewFollowContext, usePreviewFollow } from './hooks/usePreviewFollow.js';
68
69
  import { UndoToastMessage } from './components/UndoToastMessage.js';
69
70
  import { getNotificationDuration } from './utils/env.js';
70
- import { MayHaveLandedError } from './unanswered.js';
71
+ import { MayHaveLandedError, isGatewayFailure, isUnanswered } from './unanswered.js';
72
+ /** The collection at `path` in a collection tree. */
73
+ const findCollection = (cols, path) => {
74
+ if (!cols)
75
+ return undefined;
76
+ for (const col of cols) {
77
+ if (col.path === path)
78
+ return col;
79
+ const found = findCollection(col.children, path);
80
+ if (found)
81
+ return found;
82
+ }
83
+ return undefined;
84
+ };
71
85
  // Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
72
86
  // while no entry is open.
73
87
  const EMPTY_SCHEMA = [];
@@ -1012,18 +1026,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1012
1026
  }
1013
1027
  };
1014
1028
  const handleReorderEntry = async (collectionPath, contentId, direction) => {
1015
- const findCollection = (cols, path) => {
1016
- if (!cols)
1017
- return undefined;
1018
- for (const col of cols) {
1019
- if (col.path === path)
1020
- return col;
1021
- const found = findCollection(col.children, path);
1022
- if (found)
1023
- return found;
1024
- }
1025
- return undefined;
1026
- };
1027
1029
  // Resolve against `collectionsFromApi`, NOT `activeCollections`: the latter
1028
1030
  // falls back to the build-time `collections` prop whenever the fetch is
1029
1031
  // still empty -- exactly the state mid-branch-switch. A write resolved
@@ -1284,7 +1286,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1284
1286
  standIn: () => null,
1285
1287
  });
1286
1288
  const showChangesRef = useRef(null);
1289
+ // A confirm never opens over the drawer, nor while it closes: its focus return would take
1290
+ // focus out of the confirm. So "Delete entry" closes the drawer, and its exit opens the confirm.
1291
+ const deleteAfterChangesRef = useRef();
1287
1292
  useEffect(() => {
1293
+ deleteAfterChangesRef.current = undefined;
1288
1294
  setChangesOpen(false);
1289
1295
  setChangesShown(false);
1290
1296
  }, [branchNameState]);
@@ -1321,6 +1327,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1321
1327
  const fields = entriesState.find((e) => e.path === entry.logicalPath)?.schema ??
1322
1328
  entriesState.find((e) => e.collectionPath === entry.collectionPath && e.entryType === entry.entryType)?.schema ??
1323
1329
  EMPTY_SCHEMA;
1330
+ deleteAfterChangesRef.current = undefined;
1324
1331
  setChangesSubject({
1325
1332
  logicalPath: entry.logicalPath,
1326
1333
  contentId: entry.contentId,
@@ -1350,6 +1357,108 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1350
1357
  const changesPullRequestUrl = isReviewer(userContext?.groups)
1351
1358
  ? currentBranch?.pullRequestUrl
1352
1359
  : undefined;
1360
+ // The drawer's undo, delete and restore follow the form's rules: hidden without edit access,
1361
+ // disabled with the branch lock's reason while the branch takes no writes. Undo writes the open
1362
+ // entry's draft, through the functional update the remove-Undo uses, so it is an unsaved edit.
1363
+ const changesSubjectOpen = !!changesSubject &&
1364
+ !!currentEntry &&
1365
+ changesSubject.status !== 'deleted' &&
1366
+ changesSubject.logicalPath === currentEntry.path &&
1367
+ (changesSubject.contentId === undefined || changesSubject.contentId === currentEntry.contentId);
1368
+ const changesLockReason = branchContentLocked
1369
+ ? describeBranchLock({
1370
+ branchStatus: currentBranch?.status,
1371
+ branchReadOnly: currentBranch?.readOnly,
1372
+ branchWriteBlocked: true,
1373
+ branchPending,
1374
+ branchMissing,
1375
+ reviewing: branchReviewing,
1376
+ pullRequestClosed: branchPullRequestClosed,
1377
+ approveBlocked: currentBranch?.approveBlocked,
1378
+ })
1379
+ : undefined;
1380
+ const changesUndo = changesSubjectOpen && currentEntry && displayValue
1381
+ ? {
1382
+ // What the form shows: a draft hidden behind a lock isn't what the cards compare.
1383
+ value: displayValue,
1384
+ saved: loadedValues[currentEntry.contentId],
1385
+ update: (update) => {
1386
+ if (contentReadOnlyRef.current)
1387
+ return;
1388
+ const contentId = currentEntry.contentId;
1389
+ setDrafts((prev) => {
1390
+ const value = prev[contentId] ?? loadedValuesRef.current[contentId];
1391
+ if (!value)
1392
+ return prev;
1393
+ const next = update(value);
1394
+ return next === value ? prev : { ...prev, [contentId]: next };
1395
+ });
1396
+ },
1397
+ threadsWithin: (path) => openThreadsWithin(displayComments, currentEntry.path, path),
1398
+ }
1399
+ : undefined;
1400
+ // An entry the branch deleted has no row to carry `canEdit`; its collection's `canAddEntry`
1401
+ // stands in, and the server judges the entry's own path.
1402
+ const deletedSubjectCollection = changesSubject?.status === 'deleted'
1403
+ ? changesSubject.logicalPath.slice(0, changesSubject.logicalPath.lastIndexOf('/'))
1404
+ : undefined;
1405
+ const restoreRefusedByPath = deletedSubjectCollection !== undefined &&
1406
+ findCollection(collectionsFromApi, deletedSubjectCollection)?.canAddEntry === false;
1407
+ const changesRestoreEntry = changesSubject?.status === 'deleted' && changesSubject.contentId
1408
+ ? async () => {
1409
+ const { logicalPath, contentId, title } = changesSubject;
1410
+ const branch = branchNameState;
1411
+ let answer;
1412
+ try {
1413
+ answer = await apiClient.changes.restore({ branch, entryPath: logicalPath }, { id: contentId });
1414
+ }
1415
+ catch (err) {
1416
+ if (!isUnanswered(err)) {
1417
+ return { tone: 'error', message: 'Couldn’t restore this entry. Try again.' };
1418
+ }
1419
+ }
1420
+ // A 409 whose write may have run is as unanswered as a timeout.
1421
+ const outcomeUnknown = !!answer &&
1422
+ !answer.ok &&
1423
+ (isGatewayFailure(answer) || answer.code === 'WRITE_OUTCOME_UNKNOWN');
1424
+ if (answer && !answer.ok && !outcomeUnknown) {
1425
+ return {
1426
+ tone: answer.status === 409 ? 'warning' : 'error',
1427
+ message: answer.error ?? 'Couldn’t restore this entry. Try again.',
1428
+ };
1429
+ }
1430
+ const entries = await refreshEntries(branch).catch(() => undefined);
1431
+ void branchChanges.reload();
1432
+ if (answer?.ok && answer.data?.warning) {
1433
+ notifications.show({ message: answer.data.warning, color: 'yellow' });
1434
+ }
1435
+ if (answer?.ok && !entries) {
1436
+ notifications.show({
1437
+ message: `"${title}" is restored. Refresh the page to see it in the "Content" panel.`,
1438
+ color: 'yellow',
1439
+ });
1440
+ setChangesOpen(false);
1441
+ return undefined;
1442
+ }
1443
+ // Unanswered, the entry list settles it: this entry, by id, listed again.
1444
+ if (!answer?.ok &&
1445
+ !entries?.some((e) => e.path === logicalPath && e.contentId === contentId)) {
1446
+ return {
1447
+ tone: 'warning',
1448
+ message: `Restoring "${title}" may have gone through, but the server couldn’t confirm it. Check the "Content" panel for it before trying again.`,
1449
+ };
1450
+ }
1451
+ setChangesOpen(false);
1452
+ openEntry(logicalPath);
1453
+ return undefined;
1454
+ }
1455
+ : undefined;
1456
+ const changesDeleteEntry = changesSubjectOpen && changesSubject?.status === 'added'
1457
+ ? () => {
1458
+ deleteAfterChangesRef.current = changesSubject.logicalPath;
1459
+ setChangesOpen(false);
1460
+ }
1461
+ : undefined;
1353
1462
  const runSaveAll = () => {
1354
1463
  void handleSaveAll()
1355
1464
  .then((result) => {
@@ -1729,10 +1838,20 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1729
1838
  if (namesBranch(name, branchNameState) && isSaveAllRunning())
1730
1839
  return;
1731
1840
  handleArchive(name).catch((err) => console.error(err));
1732
- }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), changesSubject && (_jsx(LazyEntryChangesDrawer, { opened: changesOpen, onClose: () => setChangesOpen(false), onExited: () => setChangesShown(false), returnFocusTo: changesReturnTarget, base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch', subject: {
1841
+ }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), changesSubject && (_jsx(LazyEntryChangesDrawer, { opened: changesOpen, onClose: () => setChangesOpen(false), onExited: () => {
1842
+ setChangesShown(false);
1843
+ const deleting = deleteAfterChangesRef.current;
1844
+ deleteAfterChangesRef.current = undefined;
1845
+ // Opened as from the entry menu, whose trigger stands in for the gone button.
1846
+ if (deleting && deleting === changesSubject.logicalPath) {
1847
+ handleDeleteEntry(deleting, true);
1848
+ }
1849
+ }, returnFocusTo: changesReturnTarget, base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch', subject: {
1733
1850
  ...changesSubject,
1734
1851
  unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
1735
- }, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl })), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchLabel, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
1852
+ }, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl, undo: changesUndo, noEditReason: (changesSubjectOpen && currentEntry?.canEdit === false) || restoreRefusedByPath
1853
+ ? NO_EDIT_ACCESS_REASON
1854
+ : undefined, lockedReason: changesLockReason, onDeleteEntry: changesDeleteEntry, onRestoreEntry: changesRestoreEntry })), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchLabel, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
1736
1855
  ? (path, entryType) => addEntryType(path, entryType)
1737
1856
  : undefined, onUpdateEntryType: editingCollection
1738
1857
  ? (path, name, updates) => updateEntryType(path, name, updates)
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Button, Group, Stack, Textarea } from '@mantine/core';
4
+ import { Button, Group, Stack, Textarea, Tooltip } from '@mantine/core';
5
5
  import { moveFocusIfUnmoved } from './move-focus-if-unmoved.js';
6
6
  import { CommentWriteAlert, toCommentWriteFailure, } from './CommentWriteAlert.js';
7
+ const RESOLVE_BLOCKED_REASON = "Only the person who started this thread or a reviewer can resolve it. Reply to let them know it's done.";
7
8
  /** Refocuses `box` when a submit settles: the busy button disabled itself and dropped focus. */
8
9
  export const useRefocusAfterSubmit = (submitting, box) => {
9
10
  const wasSubmitting = useRef(false);
@@ -90,6 +91,8 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
90
91
  }
91
92
  };
92
93
  const mayResolve = canResolve || thread.authorId === currentUserId;
93
- const resolveButton = mayResolve && (_jsx(Button, { variant: "light", color: "green", onClick: handleResolve, loading: resolving, disabled: replying, "data-testid": "resolve-thread-button", children: hasDraft ? 'Reply and resolve' : 'Resolve' }));
94
+ // Shown disabled with its reason rather than hidden: the same user may resolve threads they start.
95
+ // `data-disabled` without native `disabled` keeps it focusable, so the Tooltip can open on focus.
96
+ const resolveButton = mayResolve ? (_jsx(Button, { variant: "light", color: "green", onClick: handleResolve, loading: resolving, disabled: replying, "data-testid": "resolve-thread-button", children: hasDraft ? 'Reply and resolve' : 'Resolve' })) : (_jsx(Tooltip, { label: RESOLVE_BLOCKED_REASON, multiline: true, maw: 280, events: { hover: true, focus: true, touch: true }, children: _jsx(Button, { variant: "light", color: "green", "data-disabled": true, "aria-disabled": true, onClick: (e) => e.preventDefault(), "data-testid": "resolve-thread-button", children: "Resolve" }) }));
94
97
  return (_jsxs(Stack, { gap: "xs", children: [error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), expanded ? (_jsxs(_Fragment, { children: [_jsx(Textarea, { ref: boxRef, placeholder: "Write a reply\u2026", "aria-label": "Reply to this thread", value: replyText, onChange: (e) => setReplyText(e.target.value), minRows: 2, disabled: replying || resolving }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { onClick: handleReply, loading: replying, disabled: !replyText.trim() || resolving, "data-testid": "submit-reply-button", children: "Reply" }), _jsx(Button, { variant: "subtle", color: "gray", onClick: collapse, disabled: replying, children: "Cancel" }), resolveButton] })] })) : (_jsxs(Group, { gap: "xs", children: [_jsx(Button, { ref: replyButtonRef, variant: "light", onClick: expand, disabled: resolving, "data-testid": "reply-thread-button", children: "Reply\u2026" }), resolveButton] }))] }));
95
98
  };
@@ -6,6 +6,7 @@
6
6
  import React from 'react';
7
7
  import type { BranchChangeEntry } from '../../api/branch-changes.js';
8
8
  import type { FieldConfig } from '../../config/index.js';
9
+ import type { CanopyPathSegment } from '../canopy-path.js';
9
10
  import type { EntryChangeState } from '../hooks/useBranchChangesData.js';
10
11
  /** The entry a drawer compares, as the editor knows it. */
11
12
  export interface EntryChangesSubject {
@@ -41,6 +42,41 @@ export interface EntryChangesDrawerProps {
41
42
  onOpenEntry?: () => void;
42
43
  /** The branch's pull request, offered where this drawer can't compare. */
43
44
  pullRequestUrl?: string;
45
+ /** Undoing the entry's changes in its form; given only for the open entry. */
46
+ undo?: EntryChangesUndo;
47
+ /**
48
+ * Why this user can't change the entry at all, such as a path rule: its undo, delete and
49
+ * restore controls are hidden and this one line says why.
50
+ */
51
+ noEditReason?: string;
52
+ /**
53
+ * Why those controls can't act now, such as a locked branch: they stay, disabled, saying so,
54
+ * as the form's do.
55
+ */
56
+ lockedReason?: string;
57
+ /** Opens the confirm that deletes an entry added on the branch, closing the drawer first. */
58
+ onDeleteEntry?: () => void;
59
+ /** Puts back an entry deleted on the branch; resolves with why it couldn't, when it couldn't. */
60
+ onRestoreEntry?: () => Promise<RestoreRefusal | undefined>;
61
+ }
62
+ /** Why "Restore entry" didn't: `warning` where nothing is lost, or the outcome is unknown. */
63
+ export interface RestoreRefusal {
64
+ message: string;
65
+ tone: 'warning' | 'error';
66
+ }
67
+ /** The open entry's form value, and the way to change its draft; see `undo-change.ts`. */
68
+ export interface EntryChangesUndo {
69
+ /** The entry's value as its form holds it, unsaved edits included. */
70
+ value: Record<string, unknown>;
71
+ /**
72
+ * Its saved value as the form loaded it, which a redo puts back: references resolved, so a
73
+ * redone entry matches its saved state and isn't left unsaved. Else the comparison's saved side.
74
+ */
75
+ saved?: Record<string, unknown>;
76
+ /** Sets the entry's draft to what `update` makes of its value at that moment. */
77
+ update: (update: (value: Record<string, unknown>) => Record<string, unknown>) => void;
78
+ /** Open field threads inside a canopy path, which taking out an item there moves to the entry. */
79
+ threadsWithin?: (path: CanopyPathSegment[]) => number;
44
80
  }
45
81
  /** The drawer's body: its states, then the summary and one card per change. */
46
82
  export declare const EntryChangesView: React.FC<Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>>;
@@ -6,15 +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, List, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
9
+ import { Alert, Badge, Box, Button, Group, List, Paper, SimpleGrid, Stack, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
10
10
  import { useMediaQuery } from '@mantine/hooks';
11
11
  import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPhotoOff, IconPlus, } from '@tabler/icons-react';
12
12
  import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
13
13
  import { useAssetContext } from '../context/index.js';
14
+ import { EDITOR_ACTIONS } from '../copy.js';
14
15
  import { computeEntryDiff, } from '../entry-diff.js';
15
16
  import { editorImageSrc } from '../media/editor-image-src.js';
17
+ import { findBodyFieldName } from '../../utils/body-field.js';
16
18
  import { formatRelativeTime } from '../relative-time.js';
17
19
  import { OverlayDrawer } from '../shell/OverlayHeader.js';
20
+ import { movedThreadsNote } from '../undo-removal.js';
21
+ import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
18
22
  import { focusReturnProps } from '../utils/focus-return.js';
19
23
  import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
20
24
  const KIND_BADGES = {
@@ -275,14 +279,31 @@ const SideColumn = ({ label, change, side, }) => {
275
279
  const words = change.text?.mode === 'words' ? change.text : undefined;
276
280
  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 } }))] }));
277
281
  };
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
+ const UNDONE_IN_FORM = 'Undone in the form. Save to keep it.';
291
+ const ALL_UNDONE_IN_FORM = 'All changes undone in the form. Save to keep them.';
292
+ // Undo and redo replace what the form holds at a change with the base's or the saved value, so
293
+ // neither acts on a change the form has edited since: the edit would be lost.
294
+ const UNSAVED_EDIT_REASON = 'This change has unsaved edits. Save them, or choose "Discard changes" in the entry\'s menu, on its name, first.';
295
+ const UNSAVED_EDITS_REASON = 'Some of these changes have unsaved edits. Save them, or choose "Discard changes" in the entry\'s menu, on its name, first.';
296
+ /** Beside the undo controls, where an undo makes the entry unsaved, in place of a new notice. */
297
+ const UNSAVED_IN_FORM = 'Unsaved edits, undos included, apply when you save.';
298
+ const NOT_IN_FORM_REASON = 'The form no longer has the item this change is in, so it can’t be undone here.';
278
299
  /** One change: its labelled breadcrumb, its kind in a word, and the values on each side. */
279
- const ChangeCard = ({ change, wholeEntry, }) => {
300
+ const ChangeCard = ({ change, wholeEntry, undo }) => {
280
301
  const moved = change.kind === 'moved' &&
281
302
  change.before?.kind === 'position' &&
282
303
  change.after?.kind === 'position'
283
304
  ? { from: change.before.position, to: change.after.position, of: change.after.of }
284
305
  : undefined;
285
- return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." }))] }));
306
+ return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." })), undo && (_jsxs(Group, { gap: "xs", mt: "xs", wrap: "wrap", align: "center", children: [_jsx(ReasonedButton, { reason: undo.reason, onClick: undo.onToggle, "aria-label": `${undo.undone ? EDITOR_ACTIONS.redoChange : EDITOR_ACTIONS.undoChange} to ${change.path.join(' › ')}`, "data-testid": "entry-change-undo", children: undo.undone ? EDITOR_ACTIONS.redoChange : EDITOR_ACTIONS.undoChange }), _jsx(Text, { size: "xs", "data-testid": "entry-change-undone", children: undo.undone ? [UNDONE_IN_FORM, undo.note].filter(Boolean).join(' ') : '' })] }))] }));
286
307
  };
287
308
  /**
288
309
  * The summary's place, a status region from the first render on, so a screen reader hears
@@ -312,7 +333,7 @@ const changeSummary = (changes, base) => {
312
333
  return `${parts.join(', ')} since ${base}`;
313
334
  };
314
335
  /** The drawer's body: its states, then the summary and one card per change. */
315
- export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl }) => {
336
+ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
316
337
  const { data, error } = state;
317
338
  const diff = useMemo(() => data &&
318
339
  computeEntryDiff({
@@ -321,6 +342,33 @@ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenE
321
342
  after: data.after,
322
343
  referenceTitle,
323
344
  }), [data, subject.fields, referenceTitle]);
345
+ // The changes the form still holds: the base against the form's value. A saved change missing
346
+ // from them is undone in the form, so the drawer keeps no undo state of its own.
347
+ const formValue = noEditReason ? undefined : undo?.value;
348
+ const heldKeys = useMemo(() => {
349
+ if (!formValue || !data?.before || !data.after)
350
+ return undefined;
351
+ const held = computeEntryDiff({
352
+ fields: subject.fields,
353
+ before: data.before,
354
+ after: sideOfFormValue(formValue, data.after),
355
+ marks: false,
356
+ });
357
+ return new Set(held.changes.map((change) => change.key));
358
+ }, [formValue, data, subject.fields]);
359
+ // What taking out an item did to its threads, said beside its undo until it is redone.
360
+ const [notes, setNotes] = useState({});
361
+ // What the last undo or redo did, said once here rather than by every card it touched. `id`
362
+ // re-keys the text, so a second action with the same words is announced too.
363
+ const [announcement, setAnnouncement] = useState();
364
+ const announce = (text) => setAnnouncement((last) => ({ id: (last?.id ?? 0) + 1, text }));
365
+ const [restoring, setRestoring] = useState(false);
366
+ const [restoreRefusal, setRestoreRefusal] = useState();
367
+ useEffect(() => {
368
+ setNotes({});
369
+ setAnnouncement(undefined);
370
+ setRestoreRefusal(undefined);
371
+ }, [subject.logicalPath, undo?.saved]);
324
372
  // A refusal no retry passes: its reason, and the ways the entry can still be looked at.
325
373
  const refusal = error?.kind === 'tooLarge' || error?.kind === 'notFound' || error?.kind === 'unreadable'
326
374
  ? error.kind
@@ -349,7 +397,95 @@ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenE
349
397
  : movedFrom
350
398
  ? undefined
351
399
  : 'Only formatting changed; no field values differ.';
352
- return (_jsxs(Stack, { gap: "sm", children: [_jsxs(StatusRegion, { children: [movedFrom && (_jsxs(Text, { size: "sm", style: { overflowWrap: 'anywhere' }, "data-testid": "entry-changes-moved", children: ["Moved from ", movedFrom] })), summary && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-summary", children: summary }))] }), errorNotice, subject.unsavedEdits && (_jsx(Alert, { variant: "light", color: "blue", icon: _jsx(IconInfoCircle, { size: 16 }), "data-testid": "entry-changes-unsaved", children: _jsx(Text, { size: "sm", children: "Unsaved edits aren\u2019t included. Save to include them." }) })), count > 0 && (_jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: diff.changes.map((change) => (_jsx(ChangeCard, { change: change, wholeEntry: wholeEntry }, change.key))) }))] }));
400
+ // Undo sets an edited entry's values back; an entry type change has no field to set back.
401
+ const typeChanged = diff.changes.some((change) => !canSetBack(change));
402
+ const undoable = !!undo && !!heldKeys && !!data.before && !!data.after && count > 0 && !typeChanged;
403
+ // The field the diff keys an md/mdx body under, as `computeEntryDiff` picks it.
404
+ const bodySide = data.after ?? data.before;
405
+ const bodyField = bodySide?.body === undefined
406
+ ? undefined
407
+ : (bodySide.bodyFieldName ?? findBodyFieldName(subject.fields));
408
+ const baseValue = data.before && formValueOfSide(data.before);
409
+ const savedValue = data.after && (undo?.saved ?? formValueOfSide(data.after));
410
+ const isUndone = (change) => !!heldKeys && !heldKeys.has(change.key);
411
+ const allUndone = undoable && diff.changes.every(isUndone);
412
+ // The form holds `change` as neither the side it was last set to has it, the saved value
413
+ // or, once undone, the base's: an edit made since, which an undo or redo would replace.
414
+ const editedSince = (change) => !!undo &&
415
+ !!savedValue &&
416
+ !!baseValue &&
417
+ !holdsAs(undo.value, isUndone(change) ? baseValue : savedValue, change, bodyField);
418
+ const cardUndo = (change) => {
419
+ if (!undoable || !undo || !baseValue || !savedValue)
420
+ return undefined;
421
+ const held = isUndone(change);
422
+ const source = held ? savedValue : baseValue;
423
+ const placeable = !!setChangeBack(undo.value, source, change, bodyField);
424
+ // A change the form can't take back, such as one inside an item it no longer has, isn't
425
+ // offered as undone.
426
+ const undone = held && placeable;
427
+ return {
428
+ undone,
429
+ reason: lockedReason ??
430
+ (!placeable ? NOT_IN_FORM_REASON : editedSince(change) ? UNSAVED_EDIT_REASON : undefined),
431
+ note: undone ? notes[change.key] : undefined,
432
+ onToggle: () => {
433
+ // Taking out an item the branch added moves its threads to the entry, as a Remove does.
434
+ const path = !undone && change.item && change.kind === 'added'
435
+ ? canopyPathOf(undo.value, change, bodyField)
436
+ : undefined;
437
+ const note = path && undo.threadsWithin && movedThreadsNote(undo.threadsWithin(path));
438
+ setNotes(({ [change.key]: _dropped, ...rest }) => note ? { ...rest, [change.key]: note } : rest);
439
+ const where = change.path.join(' › ');
440
+ announce(undone
441
+ ? `Redone: ${where}.`
442
+ : [`Undone: ${where}. Save to keep it.`, note].filter(Boolean).join(' '));
443
+ undo.update((value) => setChangeBack(value, source, change, bodyField) ?? value);
444
+ },
445
+ };
446
+ };
447
+ const sameCollection = !!subject.from &&
448
+ subject.from.slice(0, subject.from.lastIndexOf('/')) ===
449
+ subject.logicalPath.slice(0, subject.logicalPath.lastIndexOf('/'));
450
+ const editable = !noEditReason;
451
+ const restorable = !!data.before && !data.after && !!onRestoreEntry;
452
+ // With one change, its card's "Undo change" is the whole of "Undo all changes".
453
+ const undoAll = undoable && count > 1;
454
+ const entryControls = editable && (undoAll || (!data.before && onDeleteEntry) || restorable);
455
+ const restore = async () => {
456
+ if (!onRestoreEntry || restoring)
457
+ return;
458
+ setRestoring(true);
459
+ setRestoreRefusal(undefined);
460
+ try {
461
+ setRestoreRefusal(await onRestoreEntry());
462
+ }
463
+ finally {
464
+ setRestoring(false);
465
+ }
466
+ };
467
+ return (_jsxs(Stack, { gap: "sm", children: [_jsxs(StatusRegion, { children: [movedFrom && (_jsxs(Text, { size: "sm", style: { overflowWrap: 'anywhere' }, "data-testid": "entry-changes-moved", children: ["Moved from ", movedFrom] })), summary && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-summary", children: summary }))] }), errorNotice, undoable && (_jsx(VisuallyHidden, { role: "status", "data-testid": "entry-changes-announcement", children: announcement && _jsx("span", { children: announcement.text }, announcement.id) })), subject.unsavedEdits && !undoable && (_jsx(Alert, { variant: "light", color: "blue", icon: _jsx(IconInfoCircle, { size: 16 }), "data-testid": "entry-changes-unsaved", children: _jsx(Text, { size: "sm", children: "Unsaved edits aren\u2019t included. Save to include them." }) })), noEditReason && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-no-edit", children: noEditReason })), editable && undo && typeChanged && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-type-changed", children: "This entry\u2019s type changed on this branch, so its changes can\u2019t be undone here." })), editable && undo && movedFrom && (_jsxs(Text, { size: "sm", "data-testid": "entry-changes-move-hint", children: ["Undoing changes doesn\u2019t move the entry back.", sameCollection && ` To move it back, use "${EDITOR_ACTIONS.changeUrl}…".`] })), editable && lockedReason && (undoable || entryControls) && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-locked", children: lockedReason })), entryControls && (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [undoAll && undo && baseValue && savedValue && (_jsx(ReasonedButton, { reason: lockedReason ?? (diff.changes.some(editedSince) ? UNSAVED_EDITS_REASON : undefined), onClick: () => {
468
+ if (allUndone) {
469
+ setNotes({});
470
+ announce('All changes redone in the form.');
471
+ undo.update((value) => setAllBack(value, savedValue, diff.changes, bodyField));
472
+ return;
473
+ }
474
+ // Taking out the items the branch added moves their threads, as a Remove does.
475
+ const moved = diff.changes.flatMap((change) => {
476
+ if (isUndone(change) || !change.item || change.kind !== 'added')
477
+ return [];
478
+ const path = canopyPathOf(undo.value, change, bodyField);
479
+ const count = path && undo.threadsWithin ? undo.threadsWithin(path) : 0;
480
+ return count > 0 ? [{ key: change.key, count }] : [];
481
+ });
482
+ setNotes(Object.fromEntries(moved.map(({ key, count }) => [key, movedThreadsNote(count) ?? ''])));
483
+ const total = moved.reduce((sum, { count }) => sum + count, 0);
484
+ announce([ALL_UNDONE_IN_FORM, movedThreadsNote(total)].filter(Boolean).join(' '));
485
+ undo.update((value) => setAllBack(value, baseValue, diff.changes, bodyField));
486
+ }, "data-testid": "entry-changes-undo-all", children: allUndone ? EDITOR_ACTIONS.redoAllChanges : EDITOR_ACTIONS.undoAllChanges })), !data.before && onDeleteEntry && (_jsx(ReasonedButton, { reason: lockedReason, onClick: onDeleteEntry, danger: true, "data-testid": "entry-changes-delete", children: EDITOR_ACTIONS.deleteEntry })), restorable && (_jsx(ReasonedButton, { reason: lockedReason, onClick: () => void restore(), loading: restoring, "data-testid": "entry-changes-restore", children: EDITOR_ACTIONS.restoreEntry }))] })), undoable && (
487
+ // Its own line, held from the start, so the text arriving with an undo shifts nothing.
488
+ _jsx(Text, { size: "xs", mih: 20, "data-testid": "entry-changes-all-undone", children: allUndone ? ALL_UNDONE_IN_FORM : subject.unsavedEdits ? UNSAVED_IN_FORM : '' })), editable && restoreRefusal && (_jsx(Alert, { variant: "light", color: restoreRefusal.tone === 'warning' ? 'yellow' : 'red', icon: restoreRefusal.tone === 'warning' ? (_jsx(IconAlertTriangle, { size: 16 })) : (_jsx(IconAlertCircle, { size: 16 })), "data-testid": "entry-changes-restore-refused", children: _jsx(Text, { size: "sm", children: restoreRefusal.message }) })), count > 0 && (_jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: diff.changes.map((change) => (_jsx(ChangeCard, { change: change, wholeEntry: wholeEntry, undo: editable ? cardUndo(change) : undefined }, change.key))) }))] }));
353
489
  };
354
490
  /**
355
491
  * A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
@@ -5,7 +5,7 @@ import { useMediaQuery } from '@mantine/hooks';
5
5
  import { IconArrowsDiff } from '@tabler/icons-react';
6
6
  import { SHELL_HEADER_HEIGHT, SHELL_REGION_BORDER } from '../shell/EditorShell.js';
7
7
  import { describeBranchLock } from '../branch-lock.js';
8
- import { EDITOR_ACTIONS } from '../copy.js';
8
+ import { EDITOR_ACTIONS, NO_EDIT_ACCESS_REASON } from '../copy.js';
9
9
  import { EntryBreadcrumb } from './EntryBreadcrumb.js';
10
10
  import { EntrySaveButton } from './EntrySaveButton.js';
11
11
  /**
@@ -39,9 +39,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
39
39
  pullRequestClosed: branchPullRequestClosed,
40
40
  approveBlocked: branchApproveBlocked,
41
41
  });
42
- const entryLockReason = entryReadOnly
43
- ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
44
- : undefined;
42
+ const entryLockReason = entryReadOnly ? NO_EDIT_ACCESS_REASON : undefined;
45
43
  // The Tooltip hides it while the editor is busy.
46
44
  const saveBlockedReason = branchLockReason ??
47
45
  (!branchName
@@ -22,6 +22,12 @@ export declare const EDITOR_ACTIONS: {
22
22
  readonly changeUrl: "Change URL";
23
23
  readonly changeSlug: "Change slug";
24
24
  readonly showChanges: "Show changes";
25
+ readonly undoChange: "Undo change";
26
+ readonly redoChange: "Redo change";
27
+ readonly undoAllChanges: "Undo all changes";
28
+ readonly redoAllChanges: "Redo all changes";
29
+ readonly restoreEntry: "Restore entry";
30
+ readonly deleteEntry: "Delete entry";
25
31
  };
26
32
  /**
27
33
  * Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
@@ -35,6 +41,8 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
35
41
  * says how to finish the submit, where it can be retried at all.
36
42
  */
37
43
  export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
44
+ /** Why an entry's form, and every control that would change the entry, can't act for this user. */
45
+ export declare const NO_EDIT_ACCESS_REASON = "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it.";
38
46
  /** Why only an admin may delete a branch that may hold commits nothing else has. */
39
47
  export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
40
48
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */