canopycms 0.2.0-int.121 → 0.3.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +241 -78
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/cli/cli.js +0 -18
  4. package/dist/cli/generate-ai-content.js +0 -6
  5. package/dist/cli/init.js +0 -6
  6. package/dist/dev-content-watcher.js +8 -1
  7. package/dist/editor/BranchManager.d.ts +2 -0
  8. package/dist/editor/BranchManager.js +3 -3
  9. package/dist/editor/Editor.js +276 -130
  10. package/dist/editor/EntryNavigator.d.ts +44 -9
  11. package/dist/editor/EntryNavigator.js +278 -104
  12. package/dist/editor/branch-lock.d.ts +42 -0
  13. package/dist/editor/branch-lock.js +79 -0
  14. package/dist/editor/branch-status.d.ts +0 -6
  15. package/dist/editor/branch-status.js +0 -10
  16. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  17. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  18. package/dist/editor/components/BranchPill.d.ts +3 -18
  19. package/dist/editor/components/BranchPill.js +7 -10
  20. package/dist/editor/components/BranchRowLock.d.ts +3 -1
  21. package/dist/editor/components/BranchRowLock.js +4 -2
  22. package/dist/editor/components/EditorHeader.d.ts +24 -52
  23. package/dist/editor/components/EditorHeader.js +151 -71
  24. package/dist/editor/components/EditorRail.d.ts +8 -8
  25. package/dist/editor/components/EditorRail.js +7 -5
  26. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  27. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  28. package/dist/editor/components/EntryHeader.d.ts +60 -0
  29. package/dist/editor/components/EntryHeader.js +45 -0
  30. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  31. package/dist/editor/components/EntrySaveButton.js +101 -0
  32. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  33. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  34. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  35. package/dist/editor/components/RetryToastMessage.js +8 -4
  36. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  37. package/dist/editor/components/SaveAllButton.js +81 -0
  38. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  39. package/dist/editor/components/UnsavedMark.js +1 -1
  40. package/dist/editor/components/index.d.ts +1 -1
  41. package/dist/editor/components/index.js +1 -1
  42. package/dist/editor/copy.d.ts +0 -1
  43. package/dist/editor/copy.js +0 -1
  44. package/dist/editor/editor-utils.d.ts +2 -2
  45. package/dist/editor/editor-utils.js +2 -2
  46. package/dist/editor/entry-search.d.ts +10 -16
  47. package/dist/editor/entry-search.js +4 -24
  48. package/dist/editor/hooks/editor-history.d.ts +7 -0
  49. package/dist/editor/hooks/editor-history.js +80 -0
  50. package/dist/editor/hooks/useBranchActions.js +3 -1
  51. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  52. package/dist/editor/hooks/useBranchManager.js +18 -11
  53. package/dist/editor/hooks/useEntryManager.d.ts +12 -2
  54. package/dist/editor/hooks/useEntryManager.js +141 -20
  55. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  56. package/dist/editor/shell/ActivityRail.js +13 -4
  57. package/dist/editor/shell/EditorShell.d.ts +6 -7
  58. package/dist/editor/shell/EditorShell.js +9 -13
  59. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  60. package/dist/editor/shell/PaneLayout.js +23 -5
  61. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  62. package/dist/editor/shell/SidePanelHost.js +95 -7
  63. package/dist/editor/shell/TopBar.d.ts +4 -4
  64. package/dist/editor/shell/TopBar.js +6 -3
  65. package/dist/editor/theme.js +22 -1
  66. package/dist/editor/utils/focus-return.d.ts +10 -2
  67. package/dist/editor/utils/focus-return.js +40 -6
  68. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  69. package/dist/operating-mode/client-safe-strategy.js +1 -10
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  75. package/dist/editor/components/BranchLockBanner.js +0 -66
  76. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  77. package/dist/editor/components/QuickOpenPalette.js +0 -71
  78. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  79. package/dist/editor/components/SaveSplitButton.js +0 -121
@@ -5,6 +5,7 @@ import { useSWRConfig } from 'swr';
5
5
  import { Alert, Stack, Text } from '@mantine/core';
6
6
  import { IconAlertTriangle } from '@tabler/icons-react';
7
7
  import { openConfirm } from '../utils/confirm-modal.js';
8
+ import { focusReturnProps } from '../utils/focus-return.js';
8
9
  import { EDITOR_ACTIONS } from '../copy.js';
9
10
  import { notifications } from '@mantine/notifications';
10
11
  import { useApiClient } from '../context/index.js';
@@ -18,6 +19,7 @@ import { RetryToastMessage } from '../components/RetryToastMessage.js';
18
19
  import { isNonApiResponse } from '../../api/client.js';
19
20
  import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
20
21
  import { describeUnsaved } from './useBranchActions.js';
22
+ import { writeEditorUrl } from './editor-history.js';
21
23
  const BRANCHES_LOAD_FAILED_TOAST_ID = 'canopy-branches-load-failed';
22
24
  const BRANCH_OUTCOME_UNKNOWN_TOAST_ID = 'canopy-branch-outcome-unknown';
23
25
  /** The error a refused branch action throws; a gateway's 5xx says nothing of whether it ran. */
@@ -70,7 +72,7 @@ const confirmModalHandlers = (onConfirm, onDismiss) => {
70
72
  * With `unsaved` edits on the branch, the dialog says they are left out: a submit sends only what
71
73
  * is saved, and the branch is read-only in review, so they wait as drafts until it is editable.
72
74
  */
73
- const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss) => {
75
+ const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss, returnFocusTo) => {
74
76
  const leftOut = unsaved && unsaved.count > 0 ? unsaved : undefined;
75
77
  openConfirm({
76
78
  title: EDITOR_ACTIONS.submitForReview,
@@ -81,6 +83,7 @@ const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss) => {
81
83
  },
82
84
  confirmProps: { color: 'brand', 'data-testid': 'confirm-submit-branch' },
83
85
  ...confirmModalHandlers(onConfirm, onDismiss),
86
+ ...focusReturnProps(returnFocusTo),
84
87
  });
85
88
  };
86
89
  /**
@@ -101,7 +104,7 @@ export function withdrawPullRequestBullet(pr) {
101
104
  return `Leave the merged ${label} as it is`;
102
105
  return `Convert ${label} to a draft`;
103
106
  }
104
- const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
107
+ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
105
108
  const bullets = [
106
109
  withdrawPullRequestBullet(pr),
107
110
  'Change the branch status back to "editing"',
@@ -113,6 +116,7 @@ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
113
116
  labels: { confirm: EDITOR_ACTIONS.withdrawFromReview, cancel: 'Cancel' },
114
117
  confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
115
118
  ...confirmModalHandlers(onConfirm, onDismiss),
119
+ ...focusReturnProps(returnFocusTo),
116
120
  });
117
121
  };
118
122
  const showRequestChangesConfirmation = (branchName, onConfirm, onDismiss) => {
@@ -316,11 +320,11 @@ export function useBranchManager(options) {
316
320
  // (see Editor.tsx's `branchContentLocked` for the full rationale, which
317
321
  // applies identically here).
318
322
  isProtected: b.isProtected ?? true,
319
- // NOT flipped to `?? true`: `readOnly` only selects WHICH lock banner
320
- // to show (base-branch read-only vs. status lock) once something is
323
+ // NOT flipped to `?? true`: `readOnly` only selects WHICH lock the status
324
+ // names (base-branch read-only vs. status lock) once something is
321
325
  // already known to be locked via `writeBlocked`/`submitBlocked` --
322
326
  // it never gates a write by itself. Defaulting it true on missing data
323
- // would mislabel a status lock as a base-branch lock (wrong banner
327
+ // would mislabel a status lock as a base-branch lock (wrong status
324
328
  // copy), not under-lock anything, so `?? false` stays correct here.
325
329
  readOnly: b.readOnly ?? false,
326
330
  writeBlocked: b.writeBlocked ?? true,
@@ -406,7 +410,7 @@ export function useBranchManager(options) {
406
410
  }
407
411
  notifications.show({ message: err instanceof Error ? err.message : fallback, color: 'red' });
408
412
  };
409
- const handleSubmit = (requested) => {
413
+ const handleSubmit = (requested, returnFocusTo) => {
410
414
  const branchNameToSubmit = listedName(requested);
411
415
  return singleFlight('submit', branchNameToSubmit, async () => {
412
416
  // Drafts belong to the open branch, so only its submit can leave edits behind. Without
@@ -463,11 +467,11 @@ export function useBranchManager(options) {
463
467
  },
464
468
  // Dismissal is not an error: the call sites log rejections to the console. Settled
465
469
  // once, by onCancel OR onClose, so an Escape/overlay dismissal can't leave this pending.
466
- () => resolve());
470
+ () => resolve(), returnFocusTo);
467
471
  });
468
472
  });
469
473
  };
470
- const handleWithdraw = (requested) => {
474
+ const handleWithdraw = (requested, returnFocusTo) => {
471
475
  const branchNameToWithdraw = listedName(requested);
472
476
  return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
473
477
  showWithdrawConfirmation(branchNameToWithdraw, pullRequestOf(branchNameToWithdraw), async () => {
@@ -490,7 +494,7 @@ export function useBranchManager(options) {
490
494
  finally {
491
495
  options.setBusy(false);
492
496
  }
493
- }, () => resolve());
497
+ }, () => resolve(), returnFocusTo);
494
498
  }));
495
499
  };
496
500
  const handleRequestChanges = (branchNameForChanges) => singleFlight('request-changes', branchNameForChanges, () => new Promise((resolve) => {
@@ -583,16 +587,18 @@ export function useBranchManager(options) {
583
587
  const handleReloadBranchData = async () => {
584
588
  await loadBranches();
585
589
  };
590
+ // The editor's own page; a Back off it to a page of the host leaves that page's URL alone.
591
+ const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
586
592
  useEffect(() => {
587
593
  if (typeof window === 'undefined')
588
594
  return;
589
- if (!branchName)
595
+ if (!branchName || window.location.pathname !== editorPathRef.current)
590
596
  return;
591
597
  const url = new URL(window.location.href);
592
598
  const current = url.searchParams.get('branch');
593
599
  if (current !== branchName) {
594
600
  url.searchParams.set('branch', branchName);
595
- window.history.replaceState({}, '', url.toString());
601
+ writeEditorUrl(url.toString(), false);
596
602
  }
597
603
  }, [branchName]);
598
604
  return {
@@ -602,6 +608,7 @@ export function useBranchManager(options) {
602
608
  branchSummaries,
603
609
  currentBranch,
604
610
  branchesLoadFailed: !!branchesError && !branchesData,
611
+ branchesLoaded: !!branchesData,
605
612
  addCreatedBranch,
606
613
  handleSubmit,
607
614
  handleWithdraw,
@@ -3,6 +3,9 @@ import type { EditorEntry, EditorCollection } from '../Editor.js';
3
3
  import type { LogicalPath } from '../../paths/types.js';
4
4
  import type { FormValue } from '../FormRenderer.js';
5
5
  import type { EntryFieldError } from '../../validation/entry-validator.js';
6
+ import type { BranchBarStatus } from '../branch-lock.js';
7
+ /** @internal Exported for tests. */
8
+ export declare const RESTORED_ENTRY_GONE_MESSAGE = "The entry you returned to is no longer on this branch, so the home page opened.";
6
9
  /** @internal Exported for tests. */
7
10
  export { listAllEntries } from './useEntriesData.js';
8
11
  /**
@@ -37,12 +40,19 @@ export interface UseEntryManagerOptions {
37
40
  /** An entry's route on the site (`buildPreviewRoute`), which picks the home page to open first. */
38
41
  resolvePreviewRoute?: (entry: EditorEntry) => string | undefined;
39
42
  setBusy: (busy: boolean) => void;
43
+ /** The bar's row: a failed load is toasted after 'loading' ends, and never on 'not-available'. */
44
+ branchBarState?: BranchBarStatus['value'];
45
+ /** Switches to the branch a Back or Forward reached; rejects when the switch does not happen. */
46
+ switchBranch?: (branch: string) => Promise<void>;
47
+ /** Called when a Back or Forward has opened another entry. */
48
+ onHistoryRestore?: () => void;
40
49
  }
41
50
  export interface UseEntryManagerReturn {
42
51
  selectedPath: string;
52
+ /** Opens an entry the user chose, adding a history entry, so Back returns to the one before. */
43
53
  setSelectedPath: (path: string) => void;
44
- /** Leaves no entry open, as after deleting the open one, instead of opening the first. */
45
- closeEntry: () => void;
54
+ /** Leaves no entry open if `path` on `branch` is still open, as after deleting it. */
55
+ closeEntry: (path: string, branch: string) => void;
46
56
  entries: EditorEntry[];
47
57
  setEntries: (entries: EditorEntry[]) => void;
48
58
  collections: EditorCollection[];
@@ -10,7 +10,12 @@ import { SCHEMA_UNAVAILABLE_CODE } from '../unavailable-entry-type.js';
10
10
  import { entriesKey, fetchEntriesAndSchema } from './useEntriesData.js';
11
11
  import { RetryToastMessage } from '../components/RetryToastMessage.js';
12
12
  import { isGatewayFailure, isUnanswered, MayHaveLandedError, UnansweredError } from '../unanswered.js';
13
+ import { sanitizeBranchName } from '../../paths/branch-name.js';
14
+ import { isApiTimeoutError } from '../../api/client.js';
15
+ import { markEditorHistoryStart, recordHistoryMove, undoHistoryMove, writeEditorUrl, } from './editor-history.js';
13
16
  const ENTRIES_LOAD_FAILED_TOAST_ID = 'canopy-entries-load-failed';
17
+ /** @internal Exported for tests. */
18
+ export const RESTORED_ENTRY_GONE_MESSAGE = 'The entry you returned to is no longer on this branch, so the home page opened.';
14
19
  // Re-exported so existing imports of `listAllEntries` from this module keep
15
20
  // working -- the implementation lives in useEntriesData.ts alongside the
16
21
  // other fetch/key/type pieces useEntryManager shares with the SWR layer.
@@ -56,6 +61,23 @@ const EMPTY_SCHEMAS = [];
56
61
  * from an entry that crashes the editor); null opens the home page (`resolveHomeEntry`).
57
62
  */
58
63
  const readUrlEntry = () => typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('entry');
64
+ /** The current URL with `branch` and the `entry` that `selectedPath` shows, as `readUrlEntry` reads it. */
65
+ const entryUrl = (selectedPath, noEntry, branch) => {
66
+ const url = new URL(window.location.href);
67
+ if (branch)
68
+ url.searchParams.set('branch', branch);
69
+ if (selectedPath) {
70
+ url.searchParams.set('entry', selectedPath);
71
+ }
72
+ else if (noEntry) {
73
+ // Kept, so a reload still opens no entry.
74
+ url.searchParams.set('entry', '');
75
+ }
76
+ else {
77
+ url.searchParams.delete('entry');
78
+ }
79
+ return url.toString();
80
+ };
59
81
  /**
60
82
  * Custom hook for managing editor entries (CRUD operations).
61
83
  */
@@ -102,6 +124,18 @@ export function useEntryManager(options) {
102
124
  selectedPathRef.current = selectedPath;
103
125
  // Counts selections through the returned setter, not this hook's own fallbacks.
104
126
  const selectionsByCallerRef = useRef(0);
127
+ // The path the user chose, which the URL sync pushes; any other selection replaces the URL.
128
+ const pushTargetRef = useRef(null);
129
+ // The `entry` a Back onto another branch opens once that branch shows; a later move drops it.
130
+ const entryAfterBranchChangeRef = useRef(undefined);
131
+ // Counts Back and Forward moves, so a branch switch one asked for knows when another followed.
132
+ const historyMovesRef = useRef(0);
133
+ // The path a Back or Forward opened, until the entries show whether it is still there.
134
+ const restoredPathRef = useRef(null);
135
+ // The editor's own page: a Back off it to another page of the host is no move between entries.
136
+ const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
137
+ const onHistoryRestoreRef = useRef(options.onHistoryRestore);
138
+ onHistoryRestoreRef.current = options.onHistoryRestore;
105
139
  const [navigatorOpen, setNavigatorOpen] = useState(() => readUrlEntry() === '');
106
140
  const isInitialMount = useRef(true);
107
141
  const hasSyncedFromUrl = useRef(false);
@@ -188,13 +222,18 @@ export function useEntryManager(options) {
188
222
  const noEntryRequested = useRef(initialUrlEntry.current === '');
189
223
  const selectEntryPath = useCallback((path) => {
190
224
  selectionsByCallerRef.current += 1;
225
+ pushTargetRef.current = path;
191
226
  setSelectedPath(path);
192
227
  }, []);
193
228
  // No entry open, as the URL's empty `entry` asks, where a lost selection opens the home page.
194
- const closeEntry = useCallback(() => {
229
+ // Replaces the history entry: it follows a delete, which Back could not undo.
230
+ const closeEntry = useCallback((path, branch) => {
231
+ if (selectedPathRef.current !== path || currentBranchRef.current !== branch)
232
+ return;
195
233
  noEntryRequested.current = true;
196
- selectEntryPath('');
197
- }, [selectEntryPath]);
234
+ selectionsByCallerRef.current += 1;
235
+ setSelectedPath('');
236
+ }, []);
198
237
  const collectionByPath = useMemo(() => {
199
238
  const map = new Map();
200
239
  const walk = (collections) => {
@@ -470,6 +509,7 @@ export function useEntryManager(options) {
470
509
  // still empty when this function runs (slow server / cold start).
471
510
  const createdEntry = refreshed.find((e) => e.collectionPath === createModalCollection.path && e.slug === slug);
472
511
  if (createdEntry) {
512
+ pushTargetRef.current = createdEntry.path;
473
513
  setSelectedPath(createdEntry.path);
474
514
  }
475
515
  else {
@@ -566,8 +606,16 @@ export function useEntryManager(options) {
566
606
  isInitialMount.current = false;
567
607
  }
568
608
  else {
569
- noEntryRequested.current = false;
570
- setSelectedPath('');
609
+ const restored = entryAfterBranchChangeRef.current;
610
+ entryAfterBranchChangeRef.current = undefined;
611
+ const entry = restored?.branch === options.branchName ? restored.entry : undefined;
612
+ noEntryRequested.current = entry === '';
613
+ setSelectedPath(entry ?? '');
614
+ if (entry !== undefined) {
615
+ restoredPathRef.current = entry;
616
+ setNavigatorOpen(entry === '');
617
+ onHistoryRestoreRef.current?.();
618
+ }
571
619
  // Bound version-map growth across switches. Correctness no longer
572
620
  // depends on this clear: tokens are keyed by `${branch}:${contentId}`
573
621
  // (see entryVersionsRef), so a late response from the previous branch
@@ -659,12 +707,14 @@ export function useEntryManager(options) {
659
707
  // to a branch that fails again still reports.
660
708
  const notifiedErrorBranchRef = useRef(null);
661
709
  useEffect(() => {
662
- if (!entriesError || !options.branchName) {
710
+ if (!entriesError || !options.branchName || options.branchBarState === 'not-available') {
663
711
  if (notifiedErrorBranchRef.current !== null)
664
712
  notifications.hide(ENTRIES_LOAD_FAILED_TOAST_ID);
665
713
  notifiedErrorBranchRef.current = null;
666
714
  return;
667
715
  }
716
+ if (options.branchBarState === 'loading')
717
+ return;
668
718
  if (notifiedErrorBranchRef.current === options.branchName)
669
719
  return;
670
720
  const branch = options.branchName;
@@ -675,8 +725,12 @@ export function useEntryManager(options) {
675
725
  notifications.show({
676
726
  id: ENTRIES_LOAD_FAILED_TOAST_ID,
677
727
  title: 'Could not load content',
728
+ // A timeout's message is written for people; any other is a diagnostic, kept for Details.
678
729
  message: createElement(RetryToastMessage, {
679
- message: getErrorMessage(entriesError),
730
+ message: isApiTimeoutError(entriesError)
731
+ ? entriesError.message
732
+ : "The editor couldn't fetch this branch's entries.",
733
+ details: isApiTimeoutError(entriesError) ? undefined : getErrorMessage(entriesError),
680
734
  onRetry: () => {
681
735
  notifications.hide(ENTRIES_LOAD_FAILED_TOAST_ID);
682
736
  notifiedErrorBranchRef.current = null;
@@ -686,7 +740,7 @@ export function useEntryManager(options) {
686
740
  color: 'red',
687
741
  autoClose: false,
688
742
  });
689
- }, [entriesError, options.branchName]);
743
+ }, [entriesError, options.branchName, options.branchBarState]);
690
744
  // Validate selected entry when entries change
691
745
  useEffect(() => {
692
746
  if (selectedPath)
@@ -703,33 +757,100 @@ export function useEntryManager(options) {
703
757
  return;
704
758
  }
705
759
  }
760
+ const restored = restoredPathRef.current === selectedPath;
761
+ if (restored)
762
+ restoredPathRef.current = null;
706
763
  // If the selected entry exists, keep it
707
764
  if (entriesState.find((e) => e.path === selectedPath))
708
765
  return;
709
766
  if (selectedPath === '' && noEntryRequested.current)
710
767
  return;
768
+ if (restored && selectedPath) {
769
+ notifications.show({ message: RESTORED_ENTRY_GONE_MESSAGE, color: 'blue' });
770
+ }
711
771
  setSelectedPath(resolveHomeEntry(entriesState, resolvePreviewRouteRef.current)?.path ?? '');
712
772
  }, [entriesState, selectedPath]);
713
773
  // Update URL when selection changes (skip until URL sync has happened)
714
774
  useEffect(() => {
715
775
  if (typeof window === 'undefined')
716
776
  return;
777
+ // Pushed only once listed: a path the check above replaces would leave a stale entry behind.
778
+ const push = pushTargetRef.current === selectedPath && entriesState.some((e) => e.path === selectedPath);
779
+ pushTargetRef.current = null;
717
780
  // Don't update URL until we've synced from it first
718
781
  if (!hasSyncedFromUrl.current)
719
782
  return;
720
- const url = new URL(window.location.href);
721
- if (selectedPath) {
722
- url.searchParams.set('entry', selectedPath);
723
- }
724
- else if (noEntryRequested.current) {
725
- // Kept, so a reload still opens no entry.
726
- url.searchParams.set('entry', '');
727
- }
728
- else {
729
- url.searchParams.delete('entry');
730
- }
731
- window.history.replaceState({}, '', url.toString());
783
+ if (window.location.pathname !== editorPathRef.current)
784
+ return;
785
+ writeEditorUrl(entryUrl(selectedPath, noEntryRequested.current, currentBranchRef.current), push);
732
786
  }, [selectedPath]);
787
+ const entriesRef = useRef(entriesState);
788
+ entriesRef.current = entriesState;
789
+ const switchBranchRef = useRef(options.switchBranch);
790
+ switchBranchRef.current = options.switchBranch;
791
+ // Back and Forward open the entry their URL names, without a reload.
792
+ useEffect(() => {
793
+ if (typeof window === 'undefined')
794
+ return;
795
+ markEditorHistoryStart();
796
+ const restore = () => {
797
+ const moved = recordHistoryMove();
798
+ if (moved === null || window.location.pathname !== editorPathRef.current)
799
+ return;
800
+ historyMovesRef.current += 1;
801
+ entryAfterBranchChangeRef.current = undefined;
802
+ const params = new URLSearchParams(window.location.search);
803
+ const entry = params.get('entry');
804
+ const branch = params.get('branch');
805
+ const current = currentBranchRef.current;
806
+ // A legacy name and its sanitized form are one branch; a switch would key drafts by the other.
807
+ if (branch && sanitizeBranchName(branch) !== sanitizeBranchName(current)) {
808
+ const move = historyMovesRef.current;
809
+ const stay = () => {
810
+ if (move !== historyMovesRef.current)
811
+ return;
812
+ if (moved)
813
+ undoHistoryMove(moved);
814
+ else {
815
+ const url = entryUrl(selectedPathRef.current, noEntryRequested.current, currentBranchRef.current);
816
+ writeEditorUrl(url, false);
817
+ }
818
+ };
819
+ const switchBranch = switchBranchRef.current;
820
+ if (!switchBranch) {
821
+ stay();
822
+ return;
823
+ }
824
+ entryAfterBranchChangeRef.current = { branch, entry };
825
+ if (!hasSyncedFromUrl.current)
826
+ initialUrlEntry.current = entry;
827
+ switchBranch(branch).catch(() => {
828
+ if (move === historyMovesRef.current)
829
+ entryAfterBranchChangeRef.current = undefined;
830
+ stay();
831
+ });
832
+ return;
833
+ }
834
+ // A Back is a selection, so a rename, move or re-create landing afterwards leaves it open.
835
+ selectionsByCallerRef.current += 1;
836
+ noEntryRequested.current = entry === '';
837
+ setNavigatorOpen(entry === '');
838
+ if (!hasSyncedFromUrl.current) {
839
+ initialUrlEntry.current = entry;
840
+ setSelectedPath(entry ?? '');
841
+ return;
842
+ }
843
+ const target = entry ?? resolveHomeEntry(entriesRef.current, resolvePreviewRouteRef.current)?.path ?? '';
844
+ if (target === selectedPathRef.current)
845
+ return;
846
+ restoredPathRef.current = target;
847
+ // A path no longer listed falls back to the home page, through the selection check above.
848
+ setSelectedPath(target);
849
+ onHistoryRestoreRef.current?.();
850
+ };
851
+ window.addEventListener('popstate', restore);
852
+ return () => window.removeEventListener('popstate', restore);
853
+ }, []);
733
854
  return {
734
855
  selectedPath,
735
856
  setSelectedPath: selectEntryPath,
@@ -26,10 +26,14 @@ export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>
26
26
  shortcut?: string;
27
27
  /** A count badge, hidden at zero; the label should say it too. */
28
28
  count?: number;
29
+ /** A mark at the button's top-right corner, such as an unsaved dot; the label should say it too. */
30
+ corner?: ReactNode;
29
31
  countTestId?: string;
30
32
  tooltipDisabled?: boolean;
31
33
  /** Disables the button, which stays focusable, and its tooltip gives this reason instead. */
32
34
  disabledReason?: string;
33
35
  }
36
+ /** Sets the buttons after it apart. It carries no `data-rail-item`, so the arrow keys skip it. */
37
+ export declare function RailDivider(): import("react/jsx-runtime").JSX.Element;
34
38
  /** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
35
39
  export declare const RailButton: React.ForwardRefExoticComponent<RailButtonProps & React.RefAttributes<HTMLButtonElement>>;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { forwardRef, useId, useLayoutEffect, useRef, useState, } from 'react';
4
- import { ActionIcon, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
4
+ import { ActionIcon, Box, Divider, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
5
5
  import { useElementSize, useMergedRef } from '@mantine/hooks';
6
6
  const ACTIVITY_RAIL_WIDTH = 44;
7
7
  const RAIL_ITEM_ATTR = 'data-rail-item';
@@ -74,18 +74,27 @@ export function ActivityRail({ top, bottom, account }) {
74
74
  }
75
75
  }, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
76
76
  }
77
+ /** Sets the buttons after it apart. It carries no `data-rail-item`, so the arrow keys skip it. */
78
+ export function RailDivider() {
79
+ return _jsx(Divider, { w: 24, my: 2 });
80
+ }
77
81
  /** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
78
- export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
82
+ export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, corner, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
79
83
  const reasonId = useId();
80
84
  const button = (_jsxs(ActionIcon, { ref: ref,
81
85
  // Filled when active: a tint alone would show the state by colour, under 3:1.
82
86
  variant: active ? 'filled' : 'subtle', color: active ? undefined : 'gray', size: "lg", radius: "md", "aria-label": label, ...rest, "aria-disabled": disabledReason ? true : rest['aria-disabled'], "aria-describedby": disabledReason ? reasonId : rest['aria-describedby'], "data-disabled": disabledReason ? true : undefined, onClick: disabledReason ? undefined : onClick, [RAIL_ITEM_ATTR]: railId, children: [icon, disabledReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: disabledReason }))] }));
83
- return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsx(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
87
+ return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsxs(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
84
88
  indicator: {
85
89
  pointerEvents: 'none',
86
90
  // Mantine paints the pill again in a ::before, from this variable.
87
91
  '--indicator-color': 'var(--canopy-count-bg)',
88
92
  '--indicator-text-color': 'var(--mantine-color-text)',
89
93
  },
90
- }, children: button }) }));
94
+ }, children: [button, corner && (_jsx(Box, { component: "span", "aria-hidden": true, pos: "absolute", top: 5, right: 5, style: {
95
+ pointerEvents: 'none',
96
+ lineHeight: 0,
97
+ borderRadius: '50%',
98
+ boxShadow: '0 0 0 2px var(--mantine-color-body)',
99
+ }, children: corner }))] }) }));
91
100
  });
@@ -1,14 +1,13 @@
1
1
  import { type ReactNode } from 'react';
2
2
  export interface EditorShellProps {
3
3
  topBar?: ReactNode;
4
- banner?: ReactNode;
5
4
  rail?: ReactNode;
6
5
  panel?: ReactNode;
7
6
  main: ReactNode;
8
7
  /** Drawers, modals and palettes, each portalling itself into `<body>`. */
9
8
  overlays?: ReactNode;
10
9
  }
11
- /** The line between two shell regions, as under the top bar and a shown banner. */
10
+ /** The line between two shell regions, as under the top bar. */
12
11
  export declare const SHELL_REGION_BORDER = "1px solid var(--canopy-input-border)";
13
12
  /**
14
13
  * Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
@@ -24,9 +23,9 @@ export declare const DIVIDER_TARGET_PX: {
24
23
  /** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
25
24
  export declare const DIVIDER_COLOR = "var(--canopy-input-border)";
26
25
  /**
27
- * The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
28
- * overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
29
- * and whose scrolling it stops. Layout only: features plug into the regions. The rail sits at the
30
- * inline start, opposite the toasts (`theme.tsx`), and comes first after the banner in focus order.
26
+ * The editor's window: a full-viewport grid of regions (top bar, rail, panel, main, overlays),
27
+ * portalled into `<body>` above the host page, whose chrome it hides and makes inert and whose
28
+ * scrolling it stops. Layout only: features plug into the regions. The rail sits at the inline
29
+ * start, opposite the toasts (`theme.tsx`), and comes first after the top bar in focus order.
31
30
  */
32
- export declare function EditorShell({ topBar, banner, rail, panel, main, overlays }: EditorShellProps): import("react/jsx-runtime").JSX.Element;
31
+ export declare function EditorShell({ topBar, rail, panel, main, overlays }: EditorShellProps): import("react/jsx-runtime").JSX.Element;
@@ -4,7 +4,7 @@ import { useRef } from 'react';
4
4
  import { Portal, getDefaultZIndex } from '@mantine/core';
5
5
  import { RemoveScroll } from 'react-remove-scroll';
6
6
  import { useHideHostChrome } from './host-chrome.js';
7
- /** The line between two shell regions, as under the top bar and a shown banner. */
7
+ /** The line between two shell regions, as under the top bar. */
8
8
  export const SHELL_REGION_BORDER = '1px solid var(--canopy-input-border)';
9
9
  /**
10
10
  * Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
@@ -16,10 +16,6 @@ export const DIVIDER_WIDTH_PX = 4;
16
16
  export const DIVIDER_TARGET_PX = { fine: 12, coarse: 16 };
17
17
  /** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
18
18
  export const DIVIDER_COLOR = 'var(--canopy-input-border)';
19
- // A banner component may render nothing, so the region's line follows its content, not the prop.
20
- const SHELL_STYLES = `
21
- [data-canopy-region='banner']:not(:empty) { border-block-end: ${SHELL_REGION_BORDER}; }
22
- `;
23
19
  const gridStyle = {
24
20
  position: 'fixed',
25
21
  inset: 0,
@@ -27,21 +23,21 @@ const gridStyle = {
27
23
  // under Mantine's drawers and modals, leaving the layer between for MDXEditor's popups.
28
24
  zIndex: getDefaultZIndex('modal') - 2,
29
25
  display: 'grid',
30
- gridTemplateAreas: '"topbar topbar topbar" "banner banner banner" "rail panel main"',
31
- gridTemplateRows: 'auto auto minmax(0, 1fr)',
26
+ gridTemplateAreas: '"topbar topbar topbar" "rail panel main"',
27
+ gridTemplateRows: 'auto minmax(0, 1fr)',
32
28
  gridTemplateColumns: 'auto auto minmax(0, 1fr)',
33
29
  background: 'var(--canopy-surface-subtle)',
34
30
  color: 'var(--mantine-color-text)',
35
31
  };
36
32
  const region = (area) => ({ gridArea: area, minWidth: 0, minHeight: 0 });
37
33
  /**
38
- * The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
39
- * overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
40
- * and whose scrolling it stops. Layout only: features plug into the regions. The rail sits at the
41
- * inline start, opposite the toasts (`theme.tsx`), and comes first after the banner in focus order.
34
+ * The editor's window: a full-viewport grid of regions (top bar, rail, panel, main, overlays),
35
+ * portalled into `<body>` above the host page, whose chrome it hides and makes inert and whose
36
+ * scrolling it stops. Layout only: features plug into the regions. The rail sits at the inline
37
+ * start, opposite the toasts (`theme.tsx`), and comes first after the top bar in focus order.
42
38
  */
43
- export function EditorShell({ topBar, banner, rail, panel, main, overlays }) {
39
+ export function EditorShell({ topBar, rail, panel, main, overlays }) {
44
40
  const anchorRef = useRef(null);
45
41
  useHideHostChrome(anchorRef);
46
- return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [_jsx("style", { children: SHELL_STYLES }), topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), _jsx("div", { style: region('banner'), "data-canopy-region": "banner", children: banner }), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
42
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
47
43
  }
@@ -5,6 +5,7 @@ export declare const SPLIT_PERCENT_MIN = 15;
5
5
  export declare const SPLIT_PERCENT_MAX = 85;
6
6
  export declare const DEFAULT_SIDE_SPLIT_PERCENT = 52;
7
7
  export declare const DEFAULT_STACKED_SPLIT_PERCENT = 58;
8
+ export declare const FORM_PANE_ID = "canopy-form-pane";
8
9
  export interface PaneLayoutProps {
9
10
  arrangement: PaneArrangement;
10
11
  /** The preview's share (percent) beside the form; follows this prop when it changes. */
@@ -14,11 +14,14 @@ const FORM = 'canopy-form';
14
14
  // the form also keeps a usable minimum in a narrow window.
15
15
  const PREVIEW_LIMITS = { minSize: `${SPLIT_PERCENT_MIN}%`, maxSize: `${SPLIT_PERCENT_MAX}%` };
16
16
  const FORM_MIN_PX = 160;
17
+ export const FORM_PANE_ID = 'canopy-form-pane';
17
18
  const ASIDE_TOP = '--canopy-form-aside-top';
18
19
  const ASIDE_WIDTH = '--canopy-form-aside-width';
19
20
  // The pointer target is the group's `resizeTargetMinimumSize`, wider than the bar it draws. Lifted
20
- // over the panes' positioned contents, which would otherwise paint over its focus outline.
21
- const SEPARATOR_STYLES = `
21
+ // over the panes' positioned contents, which would otherwise paint over its focus outline. The form
22
+ // pane's ring is inset, since the window's edges clip it outside, and repeated on its non-scrolling
23
+ // panel above the pane, whose sticky header would cover it.
24
+ const PANE_STYLES = `
22
25
  .canopy-pane-separator {
23
26
  position: relative;
24
27
  z-index: 1;
@@ -32,6 +35,19 @@ const SEPARATOR_STYLES = `
32
35
  .canopy-pane-separator:focus-visible {
33
36
  outline: 2px solid var(--canopy-focus-ring);
34
37
  }
38
+ #${FORM_PANE_ID}:focus-visible {
39
+ outline: 2px solid var(--canopy-focus-ring);
40
+ outline-offset: -2px;
41
+ }
42
+ .canopy-form-panel:has(> #${FORM_PANE_ID}:focus-visible)::after {
43
+ outline: 2px solid var(--canopy-focus-ring);
44
+ outline-offset: -2px;
45
+ content: '';
46
+ position: absolute;
47
+ inset: 0;
48
+ z-index: 1;
49
+ pointer-events: none;
50
+ }
35
51
  `;
36
52
  const splitLayout = (percent) => ({ [PREVIEW]: percent, [FORM]: 100 - percent });
37
53
  /** The resizable preview/form split that fills the shell's main region. */
@@ -126,7 +142,7 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
126
142
  const maximizedStyle = aside
127
143
  ? { insetBlock: 0, insetInlineEnd: 0, insetInlineStart: `var(${ASIDE_WIDTH}, 0px)` }
128
144
  : { inset: 0 };
129
- return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children: SEPARATOR_STYLES }), aside && (_jsxs("div", { "data-testid": "form-aside", "data-lifted": previewMaximized || undefined, style: {
145
+ return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children: PANE_STYLES }), aside && (_jsxs("div", { "data-testid": "form-aside", "data-lifted": previewMaximized || undefined, style: {
130
146
  position: 'absolute',
131
147
  insetInlineStart: 0,
132
148
  bottom: 0,
@@ -154,12 +170,14 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
154
170
  style: previewMaximized
155
171
  ? // Over the separator, which sits over the panes.
156
172
  { overflow: 'auto', position: 'absolute', zIndex: 2, ...maximizedStyle }
157
- : { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, pos: "relative", h: "100%",
173
+ : { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, className: "canopy-form-panel", pos: "relative", h: "100%",
158
174
  // `formOverlay` stacks within this pane, so a maximized preview always covers it.
159
175
  style: {
160
176
  isolation: 'isolate',
161
177
  marginInlineStart: aside ? `var(${ASIDE_WIDTH}, 0px)` : undefined,
162
- }, children: [_jsx(Box, { ref: formRef, "data-testid": "form-pane", h: "100%", w: "100%", style: {
178
+ }, children: [_jsx(Box, { ref: formRef, id: FORM_PANE_ID,
179
+ // Takes focus when an opened entry's form has no field to focus.
180
+ tabIndex: -1, role: "region", "aria-label": "Form", "data-testid": "form-pane", h: "100%", w: "100%", style: {
163
181
  // FormHeader reads this to span the padding.
164
182
  '--canopy-form-pane-padding': '20px',
165
183
  padding: 'var(--canopy-form-pane-padding)',