@elytracms/studio 0.0.43 → 0.0.44

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 (100) hide show
  1. package/dist/components/app-views/studio-config.d.ts +12 -0
  2. package/dist/components/app-views/studio-config.js.map +1 -1
  3. package/dist/components/assets/assets-collection-view-source.d.ts +13 -1
  4. package/dist/components/assets/assets-collection-view-source.js +42 -51
  5. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  6. package/dist/components/cms/cms-store.js +9 -11
  7. package/dist/components/cms/cms-store.js.map +1 -1
  8. package/dist/components/cms/detail-editor.js +110 -338
  9. package/dist/components/cms/detail-editor.js.map +1 -1
  10. package/dist/components/cms/document-staging.d.ts +8 -3
  11. package/dist/components/cms/document-staging.js +81 -21
  12. package/dist/components/cms/document-staging.js.map +1 -1
  13. package/dist/components/cms/use-compare-selection.d.ts +27 -0
  14. package/dist/components/cms/use-compare-selection.js +29 -0
  15. package/dist/components/cms/use-compare-selection.js.map +1 -0
  16. package/dist/components/cms/use-discard-controller.d.ts +44 -0
  17. package/dist/components/cms/use-discard-controller.js +51 -0
  18. package/dist/components/cms/use-discard-controller.js.map +1 -0
  19. package/dist/components/cms/use-embed-inspector-host.d.ts +12 -0
  20. package/dist/components/cms/use-embed-inspector-host.js +17 -0
  21. package/dist/components/cms/use-embed-inspector-host.js.map +1 -0
  22. package/dist/components/cms/use-field-focus.d.ts +38 -0
  23. package/dist/components/cms/use-field-focus.js +70 -0
  24. package/dist/components/cms/use-field-focus.js.map +1 -0
  25. package/dist/components/cms/use-live-url.d.ts +41 -0
  26. package/dist/components/cms/use-live-url.js +55 -0
  27. package/dist/components/cms/use-live-url.js.map +1 -0
  28. package/dist/components/cms/use-publish-controller.d.ts +49 -0
  29. package/dist/components/cms/use-publish-controller.js +102 -0
  30. package/dist/components/cms/use-publish-controller.js.map +1 -0
  31. package/dist/components/cms/use-published-perspective.d.ts +67 -0
  32. package/dist/components/cms/use-published-perspective.js +67 -0
  33. package/dist/components/cms/use-published-perspective.js.map +1 -0
  34. package/dist/components/cms/use-restore-version.d.ts +57 -0
  35. package/dist/components/cms/use-restore-version.js +66 -0
  36. package/dist/components/cms/use-restore-version.js.map +1 -0
  37. package/dist/components/cms/use-save-mode.d.ts +35 -0
  38. package/dist/components/cms/use-save-mode.js +54 -0
  39. package/dist/components/cms/use-save-mode.js.map +1 -0
  40. package/dist/components/cms/version-compare.js +13 -16
  41. package/dist/components/cms/version-compare.js.map +1 -1
  42. package/dist/components/cms/versions-pane.js +21 -19
  43. package/dist/components/cms/versions-pane.js.map +1 -1
  44. package/dist/components/components/transfer-dialog.d.ts +11 -6
  45. package/dist/components/components/transfer-dialog.js +98 -25
  46. package/dist/components/components/transfer-dialog.js.map +1 -1
  47. package/dist/components/components/transfer.d.ts +74 -8
  48. package/dist/components/components/transfer.js +87 -2
  49. package/dist/components/components/transfer.js.map +1 -1
  50. package/dist/components/rail-store-status.d.ts +7 -5
  51. package/dist/components/rail-store-status.js +12 -9
  52. package/dist/components/rail-store-status.js.map +1 -1
  53. package/dist/components/rail-view-source.d.ts +111 -0
  54. package/dist/components/rail-view-source.js +139 -0
  55. package/dist/components/rail-view-source.js.map +1 -0
  56. package/dist/components/redirects/redirects-collection-view-source.d.ts +13 -1
  57. package/dist/components/redirects/redirects-collection-view-source.js +42 -70
  58. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  59. package/dist/components/section-state.d.ts +12 -3
  60. package/dist/components/section-state.js +8 -4
  61. package/dist/components/section-state.js.map +1 -1
  62. package/dist/components/users/users-collection-view-source.d.ts +13 -1
  63. package/dist/components/users/users-collection-view-source.js +60 -79
  64. package/dist/components/users/users-collection-view-source.js.map +1 -1
  65. package/dist/lib/ai/anthropic-provider.d.ts +29 -0
  66. package/dist/lib/ai/anthropic-provider.js +59 -0
  67. package/dist/lib/ai/anthropic-provider.js.map +1 -0
  68. package/dist/lib/ai/apply-content.d.ts +9 -0
  69. package/dist/lib/ai/apply-content.js +170 -0
  70. package/dist/lib/ai/apply-content.js.map +1 -0
  71. package/dist/lib/ai/config.d.ts +58 -0
  72. package/dist/lib/ai/config.js +134 -0
  73. package/dist/lib/ai/config.js.map +1 -0
  74. package/dist/lib/ai/fake-provider.d.ts +34 -0
  75. package/dist/lib/ai/fake-provider.js +31 -0
  76. package/dist/lib/ai/fake-provider.js.map +1 -0
  77. package/dist/lib/ai/index.d.ts +22 -0
  78. package/dist/lib/ai/index.js +23 -0
  79. package/dist/lib/ai/index.js.map +1 -0
  80. package/dist/lib/ai/knowledge.d.ts +39 -0
  81. package/dist/lib/ai/knowledge.js +60 -0
  82. package/dist/lib/ai/knowledge.js.map +1 -0
  83. package/dist/lib/ai/provenance.d.ts +20 -0
  84. package/dist/lib/ai/provenance.js +15 -0
  85. package/dist/lib/ai/provenance.js.map +1 -0
  86. package/dist/lib/ai/route.d.ts +24 -0
  87. package/dist/lib/ai/route.js +90 -0
  88. package/dist/lib/ai/route.js.map +1 -0
  89. package/dist/lib/ai/shape.d.ts +35 -0
  90. package/dist/lib/ai/shape.js +113 -0
  91. package/dist/lib/ai/shape.js.map +1 -0
  92. package/dist/lib/ai/types.d.ts +227 -0
  93. package/dist/lib/ai/types.js +2 -0
  94. package/dist/lib/ai/types.js.map +1 -0
  95. package/dist/lib/i18n/messages.en.js +4 -2
  96. package/dist/lib/i18n/messages.en.js.map +1 -1
  97. package/dist/lib/workspace/index.d.ts +9 -0
  98. package/dist/lib/workspace/index.js +11 -0
  99. package/dist/lib/workspace/index.js.map +1 -1
  100. package/package.json +8 -2
@@ -1,17 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Link, STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
3
3
  import { Check, ChevronDown, CircleAlert, CircleCheck, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
4
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
- import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, clearHiddenValues, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, urlForDocument, } from '@elytracms/core/cms-core';
4
+ import { useCallback, useMemo, useState } from 'react';
5
+ import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, } from '@elytracms/core/cms-core';
6
6
  import { blockRootsOf } from '@elytracms/core/project-graph';
7
7
  import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, CollapsibleSection, Field, Heading, Row, Spacer, Stack, Text, toast, } from '@elytracms/studio/ui';
8
8
  import { getStudioRegistry } from '@elytracms/studio/lib/registry';
9
9
  import { collectEmbedRefs, resolveReuseCapability } from '../builder/reuse';
10
10
  import { cn } from '@elytracms/studio/lib/utils';
11
- import { useOperationCache } from '@elytracms/studio/lib/query';
12
11
  import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
13
- import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue, setStudioLocale, } from '@elytracms/studio/lib/locale';
14
- import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
12
+ import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue, } from '@elytracms/studio/lib/locale';
15
13
  import { FallbackChip } from './cell';
16
14
  import { FieldConnectionControl } from './field-connection';
17
15
  import { FieldControl } from './field-control';
@@ -32,9 +30,6 @@ import { VersionsPane } from './versions-pane';
32
30
  // EC-322: the lazy-wrapped compare view — its diff UI code-splits off the detail-view
33
31
  // critical path and loads only when the Compare tab opens.
34
32
  import { VersionCompare } from './version-compare-lazy';
35
- import { useDocumentVersionsQuery } from './use-document-versions';
36
- import { discardDraftStatus, flushDirtyDraft } from './discard-draft';
37
- import { derivePublishedSnapshot, publishedVersionSnapshot } from './versions-model';
38
33
  import { IssuesPane } from './issues-pane';
39
34
  import { DetailsPane } from './details-pane';
40
35
  import { DocumentUrlPath } from './document-url-path';
@@ -43,8 +38,18 @@ import { DocumentUrlPath } from './document-url-path';
43
38
  import { EmbedInspectorProvider } from './rich-text/embed-inspector-context';
44
39
  import { EmbedInspector } from './rich-text/embed-inspector';
45
40
  import { useSetBreadcrumb } from '../breadcrumb-context';
46
- import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
47
- import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
41
+ import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
42
+ // EC-325 — the controller hooks EditorBody orchestrates (each independently
43
+ // unit-tested at its pure core); the shell threads shared draft/ref/replaceDraft
44
+ // state across their boundaries.
45
+ import { usePublishedPerspective } from './use-published-perspective';
46
+ import { useSaveMode } from './use-save-mode';
47
+ import { useDiscardController } from './use-discard-controller';
48
+ import { usePublishController, publishGate } from './use-publish-controller';
49
+ import { useFieldFocus } from './use-field-focus';
50
+ import { useCompareSelection } from './use-compare-selection';
51
+ import { useLiveUrl } from './use-live-url';
52
+ import { useEmbedInspectorHost } from './use-embed-inspector-host';
48
53
  export function DetailEditor({ projectId, collectionId, documentId, focusPath, }) {
49
54
  // AD-19: the edit shell is a portable surface over a CollectionViewSource, just
50
55
  // like the list. `DetailEditor` is the CMS entry point — it builds the CMS source
@@ -115,89 +120,24 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
115
120
  // Stable so the document-editor context value (EC-224) doesn't churn each render.
116
121
  const ref = useMemo(() => ({ collection: collection.id, id: doc.id }), [collection.id, doc.id]);
117
122
  const confirmTargets = useMemo(() => [{ collection: collection.id, id: doc.id }], [collection.id, doc.id]);
118
- // EC-226: the draft/published VIEW perspective. Draft = the editable working
119
- // draft; Published = the pinned published snapshot, rendered read-only. Only
120
- // meaningful when the document is published.
121
- const published = source.publishing?.isPublished(ref) ?? false;
122
- const [perspective, setPerspective] = useState('draft');
123
- // Snap back to Draft if the document is unpublished while viewing Published.
124
- useEffect(() => {
125
- if (!published)
126
- setPerspective('draft');
127
- }, [published]);
128
- // EC-316/EC-325 — "Discard changes" gating AND the Published-perspective
129
- // snapshot share ONE query (AD-17: the one operations-backed cache,
130
- // `use-document-versions.ts` — no raw `source.versioning.load` call
131
- // anywhere in this component). Both need exactly the same
132
- // `versions`/`publishedVersion` shape, so a second bespoke fetch on
133
- // perspective switch would just re-read what this already has. Gated the
134
- // same way the Discard-draft menu item always was: a publishing + versioned
135
- // rail AND a published document (never a wasted fetch on e.g. the users
136
- // rail or a never-published draft) — in every real `CollectionViewSource`
137
- // `capabilities.publish`/`capabilities.versions` are set identically, so
138
- // this is also exactly when the Published perspective is reachable.
139
- const canDiscard = canPublish && canVersions;
140
- const { versionsQuery, versionKey } = useDocumentVersionsQuery(ref, {
141
- enabled: canDiscard && published,
142
- });
143
- const versionsView = versionsQuery.data ?? null;
144
- const publishedVersionNumber = versionsView?.publishedVersion;
145
- const publishedSnapshotDoc = publishedVersionSnapshot(versionsView);
146
- // The Published-perspective snapshot (EC-226), derived from the query
147
- // above — no second fetch, and `derivePublishedSnapshot` guards a
148
- // disabled/not-yet-resolved query by reading as 'loading' (never a false
149
- // 'missing').
150
- const publishedSnapshot = derivePublishedSnapshot(versionsView);
151
- // The whole fieldset is read-only when viewing Published. The form/canvas
152
- // render the pinned snapshot ONLY once it has loaded — never the draft, which
153
- // must not be presented as published; until then the body shows a placeholder.
154
- const viewReadOnly = perspective === 'published';
155
- const viewDoc = viewReadOnly && publishedSnapshot.status === 'ready' ? publishedSnapshot.doc : draft;
156
- const publishedPending = viewReadOnly && publishedSnapshot.status !== 'ready';
157
- const discardCache = useOperationCache();
158
- const [discardConfirm, setDiscardConfirm] = useState(false);
159
- const [discardBusy, setDiscardBusy] = useState(false);
160
- // One pure decision (tested in discard-draft.test.ts): hidden / disabled-
161
- // with-reason / ready. Its semantic equality is the SAME comparator the
162
- // Versions pane's publish gate uses, so the two never contradict (EC-298).
163
- const discardStatus = discardDraftStatus({
164
- canDiscard,
165
- published,
166
- viewReadOnly,
167
- publishedSnapshot: publishedSnapshotDoc,
168
- collection,
169
- draft,
170
- });
171
- const discardDisabledReason = discardStatus === 'view-read-only'
172
- ? translate('cms.editor.switchToDraftToDiscard')
173
- : discardStatus === 'up-to-date'
174
- ? translate('cms.editor.discardDraftUpToDateHint')
175
- : discardStatus === 'loading'
176
- ? translate('common.loading')
177
- : undefined;
178
- // Reuses the EC-298 restore machinery (the same op the Compare view's
179
- // "Restore version" button calls) — never a parallel write path: reverting
180
- // the draft IS restoring the published version's snapshot, whole-document.
181
- async function handleDiscardDraft() {
182
- if (publishedVersionNumber === undefined)
183
- return;
184
- setDiscardBusy(true);
185
- try {
186
- // EC-325: flush a dirty in-memory buffer FIRST, mirroring the Versions
187
- // pane's/Compare view's own restores — `documents.restore` snapshots
188
- // whatever is currently PERSISTED, so an edit still sitting in the
189
- // autosave debounce window would otherwise be silently unrecoverable.
190
- flushDirtyDraft({ dirty, save });
191
- const restored = await source.versioning?.restore(ref, publishedVersionNumber, { kind: 'all' });
192
- if (restored)
193
- replaceDraft(restored);
194
- await discardCache.invalidate(versionKey);
195
- }
196
- finally {
197
- setDiscardBusy(false);
198
- setDiscardConfirm(false);
199
- }
123
+ // EC-224 — the working draft's two out-of-band mutators, shared across the
124
+ // controller hooks below and the document-editor context. `save` persists the
125
+ // current buffer; `replaceDraft` adopts a restored snapshot and bumps the nonce
126
+ // to REMOUNT the uncontrolled canvas from it (else the next edit clobbers the
127
+ // restore — the canvas builds its graph once at mount and lifts edits OUT).
128
+ function save() {
129
+ source.save(draft);
130
+ }
131
+ function replaceDraft(next) {
132
+ setDraft(next);
133
+ setRestoreNonce((n) => n + 1);
200
134
  }
135
+ // EC-226 / EC-316 / EC-325 — the Draft/Published VIEW perspective AND the pinned
136
+ // published snapshot, both derived from the ONE shared version-history query
137
+ // (AD-17: the single operations-backed cache — no raw `source.versioning.load`
138
+ // in this component). `canDiscard`/`versionKey`/`publishedVersionNumber`/
139
+ // `publishedSnapshotDoc` feed the Discard controller below off that same query.
140
+ const { perspective, setPerspective, published, canDiscard, versionKey, publishedVersionNumber, publishedSnapshotDoc, publishedSnapshot, viewReadOnly, viewDoc, publishedPending, } = usePublishedPerspective({ ref, source, canPublish, canVersions, draft });
201
141
  const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(doc), [draft, doc]);
202
142
  const schemaIssues = useMemo(() => source.validate(draft), [source, draft]);
203
143
  // EC-300/AD-16: composition embeds (hard references) whose target isn't published
@@ -210,38 +150,29 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
210
150
  const errorCount = schemaIssues.filter((i) => i.severity === 'error').length;
211
151
  const hasErrors = errorCount > 0;
212
152
  const hasUnpublishedRefs = referenceIssues.length > 0;
213
- // --- Save mode (EC-225 autosave · EC-240 explicit) -------------------------
214
- // A publishing rail AUTOSAVES: the draft persists 1s after the last edit and
215
- // publish is the deliberate commit. A non-publishing rail (users, EC-240) has no
216
- // publish step, so silent apply is the wrong default for authority data — it
217
- // SAVES EXPLICITLY instead: edits stay in the draft until the Save button, and a
218
- // navigation guard protects unsaved changes. (Role stays in `members`, applied
219
- // on Save and enforced immediately — never behind a publish pointer.)
220
- // Refs let the unmount flush read the latest values without re-subscribing.
221
- const explicitSave = !canPublish;
222
- const draftRef = useRef(draft);
223
- draftRef.current = draft;
224
- const dirtyRef = useRef(dirty);
225
- dirtyRef.current = dirty;
226
- const sourceRef = useRef(source);
227
- sourceRef.current = source;
228
- const explicitSaveRef = useRef(explicitSave);
229
- explicitSaveRef.current = explicitSave;
230
- useEffect(() => {
231
- if (explicitSave)
232
- return; // explicit rails persist only via the Save button
233
- if (!dirty)
234
- return;
235
- const timer = window.setTimeout(() => sourceRef.current.save(draftRef.current), 1000);
236
- return () => window.clearTimeout(timer);
237
- }, [dirty, draft, explicitSave]);
238
- // Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the
239
- // debounce window) so nothing is lost. Explicit rails never flush silently —
240
- // their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.
241
- useEffect(() => () => {
242
- if (!explicitSaveRef.current && dirtyRef.current)
243
- sourceRef.current.save(draftRef.current);
244
- }, []);
153
+ // EC-225 autosave · EC-240 explicit save — the persistence discipline for this
154
+ // rail (a publishing rail autosaves + flushes on unmount; a non-publishing rail
155
+ // saves explicitly and relies on the `UnsavedChangesGuard` below for navigation).
156
+ const { explicitSave } = useSaveMode({ canPublish, dirty, draft, source });
157
+ // EC-316 / EC-325 — the "Discard changes" controller: the pure enablement
158
+ // decision plus the shared restore sequence (flush → restore the pinned published
159
+ // version whole → replaceDraft → invalidate). Destructured to the names the header
160
+ // menu + confirm dialog already use.
161
+ const { status: discardStatus, disabledReason: discardDisabledReason, confirmOpen: discardConfirm, setConfirmOpen: setDiscardConfirm, busy: discardBusy, discard: handleDiscardDraft, } = useDiscardController({
162
+ canDiscard,
163
+ published,
164
+ viewReadOnly,
165
+ publishedSnapshotDoc,
166
+ publishedVersionNumber,
167
+ versionKey,
168
+ collection,
169
+ draft,
170
+ source,
171
+ docRef: ref,
172
+ replaceDraft,
173
+ dirty,
174
+ save,
175
+ });
245
176
  const errorsByField = useMemo(() => {
246
177
  const map = new Map();
247
178
  for (const issue of issues) {
@@ -282,40 +213,28 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
282
213
  // not a field-row — it takes over its tab with the full page-builder canvas.
283
214
  // Convention: such a field lives on its own dedicated fieldset tab.
284
215
  const canvasField = tabFields.find(isBlocksField);
285
- const [highlightedField, setHighlightedField] = useState(null);
286
- // EC-285: the form-path inspector's active pane, controlled so a jump to a
287
- // `sidebar`-group field can open the Details pane (where it renders). Undefined
288
- // = uncontrolled (the sidebar defaults to its first pane).
289
- const [inspectorPaneId, setInspectorPaneId] = useState();
290
- // 0.0.41: the selected rich-text component embed, if any — a `componentEmbed` node
291
- // selection swaps the inspector sidebar for the embed's prop form. Fed by the
292
- // rich-text field through the EmbedInspector context; `clearActiveEmbed` keys on the
293
- // node id so a fast A→B reselect can't let A's deselect wipe B (selection race).
294
- const [activeEmbed, setActiveEmbed] = useState(null);
295
- const embedInspectorApi = useMemo(() => ({
296
- setActiveEmbed: (payload) => setActiveEmbed(payload),
297
- clearActiveEmbed: (nodeId) => setActiveEmbed((prev) => (prev?.nodeId === nodeId ? null : prev)),
298
- }), []);
299
- // EC-298: the open version comparison. The Compare tab is STICKY — opening it
300
- // sets the selection and focuses the tab; tabbing to the real fields keeps it
301
- // open (the selection persists) so an editor can flip between diff and fields;
302
- // only its ✕ closes it. `activeTab === COMPARE_TAB` is when the diff is visible.
303
- const [compare, setCompare] = useState(null);
304
- const startCompare = useCallback((base) => {
305
- setCompare({ base, target: 'draft' });
306
- setActiveTab(COMPARE_TAB);
307
- setInspectorPaneId('versions');
308
- }, []);
309
- const setCompareBase = useCallback((base) => {
310
- setCompare((c) => (c ? { ...c, base } : { base, target: 'draft' }));
311
- }, []);
312
- const setCompareTarget = useCallback((target) => {
313
- setCompare((c) => (c ? { ...c, target } : c));
314
- }, []);
315
- const exitCompare = useCallback(() => {
316
- setCompare(null);
317
- setActiveTab((prev) => (prev === COMPARE_TAB ? tabs[0] : prev));
318
- }, [tabs]);
216
+ // EC-172 / EC-283 — the field-focus/highlight controller (jump-to-field from the
217
+ // reference deep-link `focusPath` and the Issues pane). Owns the highlight + the
218
+ // controlled inspector-pane state and the scroll/clear timers.
219
+ const { highlightedField, inspectorPaneId, setInspectorPaneId, focusField } = useFieldFocus({
220
+ collection,
221
+ source,
222
+ focusPath,
223
+ setActiveTab,
224
+ });
225
+ // 0.0.41 — the selection-driven embed-inspector host: a rich-text `componentEmbed`
226
+ // selection swaps the inspector sidebar for the embed's prop form (the `api` is fed
227
+ // to the rich-text field through the EmbedInspector context).
228
+ const { activeEmbed, embedInspectorApi } = useEmbedInspectorHost();
229
+ // EC-298 — the sticky version-compare selection. Opening it focuses the Compare tab
230
+ // + the Versions pane; tabbing to the real fields keeps it open (only its ✕ closes
231
+ // it). `activeTab === COMPARE_TAB` is when the diff is visible.
232
+ const { compare, startCompare, setCompareBase, setCompareTarget, exitCompare } = useCompareSelection({
233
+ tabs,
234
+ compareTab: COMPARE_TAB,
235
+ setActiveTab,
236
+ setInspectorPaneId,
237
+ });
319
238
  // A content tab (schema tab or References). Selecting one leaves the Compare
320
239
  // view VISIBLE-wise but keeps the comparison open (sticky) — the diff is one
321
240
  // click away on its still-present tab chip.
@@ -333,49 +252,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
333
252
  const titleCase = tab !== REFERENCES_TAB && declaredLabel === undefined;
334
253
  return (_jsx("button", { type: "button", "aria-pressed": active, onClick: () => setActiveTab(tab), className: cn('rounded-md px-2.5 py-1 text-xs font-medium transition-colors', titleCase && 'capitalize', active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted'), children: display }, tab));
335
254
  };
336
- // Focus a field by name: switch to its tab (and locale, when given), scroll the
337
- // control into view, and ring-highlight it. Shared by the reference deep-link
338
- // (`focusPath`, EC-172) and the Issues pane's jump-to-field (EC-283).
339
- const focusTimersRef = useRef([]);
340
- const focusField = useCallback((fieldName, localeHint) => {
341
- const field = collection.fields.find((f) => f.name === fieldName);
342
- if (!field)
343
- return;
344
- // EC-285: a `sidebar`-group field renders in the Details inspector pane, not
345
- // a content tab — open that pane instead of switching tabs.
346
- if (field.form?.group === SIDEBAR_FIELD_GROUP) {
347
- setInspectorPaneId('details');
348
- }
349
- else {
350
- const fieldTabs = tabOrder(collection.form);
351
- setActiveTab(field.form?.tab ?? fieldTabs[0] ?? 'main');
352
- }
353
- if (localeHint !== undefined && source.localeConfig.locales.includes(localeHint)) {
354
- setStudioLocale(localeHint);
355
- }
356
- setHighlightedField(field.name);
357
- // Clear any timers from a prior jump so back-to-back jumps don't fight.
358
- focusTimersRef.current.forEach((t) => window.clearTimeout(t));
359
- const scrollTimer = window.setTimeout(() => {
360
- // Some controls (relation/asset pickers) don't render the input id —
361
- // the field label (`htmlFor`) is always present.
362
- const control = document.getElementById(`f-${field.name}`) ??
363
- document.querySelector(`label[for="${CSS.escape(`f-${field.name}`)}"]`);
364
- control?.scrollIntoView({ block: 'center', behavior: 'smooth' });
365
- }, 60);
366
- const clearTimer = window.setTimeout(() => setHighlightedField(null), 3200);
367
- focusTimersRef.current = [scrollTimer, clearTimer];
368
- }, [collection, source.localeConfig]);
369
- useEffect(() => () => focusTimersRef.current.forEach((t) => window.clearTimeout(t)), []);
370
- // Reference deep links (EC-172): `?field=<index path>` pinpoints the exact field.
371
- useEffect(() => {
372
- if (!focusPath)
373
- return;
374
- const parsed = parseDocumentFieldPath(focusPath);
375
- if (!parsed)
376
- return;
377
- focusField(parsed.field, parsed.locale);
378
- }, [focusPath, focusField]);
379
255
  function getValue(field) {
380
256
  // EC-226: read from the active VIEW (the draft, or the pinned published
381
257
  // snapshot when viewing Published) — never directly from `draft`.
@@ -437,20 +313,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
437
313
  : null;
438
314
  return (_jsx(Field, { className: cn('rounded-md transition-shadow', highlightedField === field.name && 'bg-primary/5 p-2 ring-2 ring-primary/70'), label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(field) }), field.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null, localized ? _jsx(Badge, { variant: "outline", children: locale }) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " ", translate('cms.editor.readOnlyBadge')] })) : null, _jsx(Spacer, {}), connection && !fieldReadOnly ? (_jsx(FieldConnectionControl, { connection: connection, fieldName: field.name, locale: locale, onFillFromBase: () => setDraft((d) => fillFieldFromBase(collection, d, field.name, locale, source.localeConfig)), onResetToBase: () => setDraft((d) => resetFieldToBase(collection, d, field.name, locale)) })) : null] }), htmlFor: `f-${field.name}`, hint: localizedLabel(field.form?.description) || undefined, error: fieldReadOnly ? undefined : errorsByField.get(field.name), children: _jsx(FieldControl, { id: `f-${field.name}`, field: field, value: getValue(field), onChange: (v) => setValue(field, v), readOnly: fieldReadOnly }) }, field.name));
439
315
  }
440
- function save() {
441
- source.save(draft);
442
- }
443
- // EC-224: a version restore swaps the working draft for an older snapshot; the
444
- // open editor adopts it so the form/canvas re-render from the restored content
445
- // (the operation already persisted it, so it lands clean/non-dirty).
446
- function replaceDraft(next) {
447
- setDraft(next);
448
- // The canvas editor is uncontrolled after mount (it builds its graph once and
449
- // lifts edits OUT). A restore replaces the draft from outside, so bump the
450
- // nonce to REMOUNT the canvas from the restored value — otherwise it keeps the
451
- // pre-restore tree and the next edit would clobber the restore.
452
- setRestoreNonce((n) => n + 1);
453
- }
454
316
  // The frame-owned `Versions` pane (EC-224) — appended after any view-supplied
455
317
  // pane (the canvas's `Properties`). One instance feeds both the form view's
456
318
  // sidebar and the canvas view's sidebar.
@@ -511,75 +373,20 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
511
373
  ...(canVersions ? [versionsPane] : []),
512
374
  issuesPane,
513
375
  ];
514
- // EC-268: the Publish button is its own indicator — pending while the publish
515
- // round-trips, a brief success, and an error toast if it rejects at transport.
516
- const [publishState, setPublishState] = useState('idle');
517
- // EC-300/AD-16: confirm-through guard when the page embeds unpublished reusables.
518
- const [publishConfirm, setPublishConfirm] = useState(false);
519
- // 0.0.39: the split-button "Publish & close" intent — remembered across the
520
- // confirm-through dialog so a successful publish returns to the list, matching the
521
- // retired EC-316 "Save & close". Cleared on cancel or a publish failure (no navigate).
522
- const closeAfterPublishRef = useRef(false);
523
- // Gate publish on the confirm-through dialog when there are unpublished hard
524
- // references (they'd render empty on the live site); schema errors already
525
- // disable the button, so they never reach here.
526
- function handlePublish() {
527
- if (hasUnpublishedRefs) {
528
- setPublishConfirm(true);
529
- return;
530
- }
531
- void doPublish();
532
- }
533
- // 0.0.39: publish, then return to the rail's list (rail-owned `listPath`, AD-19),
534
- // the same navigation the removed "Save & close" did. Shares handlePublish's exact
535
- // gating + confirm-through; the close happens only after the publish resolves.
536
- function handlePublishAndClose() {
537
- closeAfterPublishRef.current = true;
538
- handlePublish();
539
- }
540
- async function doPublish() {
541
- // EC-273: clean conditionally-hidden values from the draft AT PUBLISH TIME.
542
- // While editing, the draft keeps them so an accidental hide (e.g. flipping a
543
- // link's type) is recoverable; publishing is the intentional moment we commit
544
- // the cleanup — into the draft AND the snapshot, so both go clean together (the
545
- // reference index updates off the saved draft, so a now-unlinked page becomes
546
- // deletable once the change is published). Also captures the latest edit in case
547
- // Publish is clicked inside the autosave debounce window; the serial persister
548
- // runs the upsert before the publish so the snapshot is exactly this draft (EC-225).
549
- const cleaned = clearHiddenValues(collection, draft);
550
- if (cleaned !== draft)
551
- setDraft(cleaned);
552
- if (cleaned !== draft || dirty)
553
- source.save(cleaned);
554
- // Publish snapshots the draft as a version and pins it (EC-224). The live
555
- // row stays a continuous draft — published-ness is the version pointer
556
- // (`source.publishing.isPublished`). Pre-gated by `disabled={hasErrors}`; the optimistic
557
- // flip reconciles against the backend (EC-225), so a logical rejection the
558
- // gate doesn't cover (schema divergence) self-corrects the badge. A hard
559
- // failure (transport) rejects the flush — surface it loudly (EC-268).
560
- setPublishState('pending');
561
- try {
562
- await source.publishing?.publish(ref);
563
- setPublishState('success');
564
- setTimeout(() => setPublishState('idle'), 1800);
565
- // "Publish & close": return to the list once the publish landed (never on a
566
- // failure). Rail-owned `listPath`, falling back to the project index (AD-19).
567
- if (closeAfterPublishRef.current) {
568
- closeAfterPublishRef.current = false;
569
- void navigate({
570
- to: STUDIO_PROJECT_ROUTE,
571
- params: { projectId, _splat: source.listPath ?? studioPath.index() },
572
- });
573
- }
574
- }
575
- catch (error) {
576
- closeAfterPublishRef.current = false;
577
- setPublishState('idle');
578
- toast.error(error instanceof Error
579
- ? translate('cms.editor.publishFailedReason', { error: error.message })
580
- : translate('cms.editor.publishFailed'));
581
- }
582
- }
376
+ // EC-268 / EC-300 / 0.0.39 — the publish controller: the publish indicator state,
377
+ // the EC-300 confirm-through guard (unpublished hard refs would render empty on
378
+ // the live site), and the "Publish & close" navigation. Handlers are named to
379
+ // match the header split button + the confirm-through dialog below.
380
+ const { publishState, publishConfirm, handlePublish, handlePublishAndClose, confirmPublish, cancelPublishConfirm, } = usePublishController({
381
+ source,
382
+ docRef: ref,
383
+ collection,
384
+ projectId,
385
+ draft,
386
+ setDraft,
387
+ dirty,
388
+ hasUnpublishedRefs,
389
+ });
583
390
  function handleConfirmedAction() {
584
391
  if (confirmAction === 'unpublish') {
585
392
  setConfirmAction(null);
@@ -628,41 +435,13 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
628
435
  ];
629
436
  }, [singleton, collection, draft, locale, source.localeConfig, source.listPath, doc.id, projectId]);
630
437
  useSetBreadcrumb(breadcrumbItems);
631
- // EC-205/EC-216: the LIVE URL derives strictly from PUBLISHED data — the
632
- // document's pinned published snapshot and its published ancestors — never the
633
- // draft (an unpublished slug/parent edit must not leak into the live URL). The
634
- // pool is exactly what the live site serves, so a published doc resolves to the
635
- // URL visitors actually get; `undefined` until the doc is published (and the
636
- // pool has loaded). The collection being routable gates the disabled hint below.
637
- const publishedDocs = usePublishedDocuments(source.projectId, collection.id);
638
- const liveUrl = useMemo(() => {
639
- const published = publishedDocs.byId.get(doc.id);
640
- if (!published)
641
- return undefined;
642
- return urlForDocument({
643
- routes: resolveDeploymentRoutes(),
644
- frontendUrl: resolveDeploymentFrontendUrl(),
645
- localeConfig: source.localeConfig,
646
- }, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
647
- }, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
648
- // 0.0.42 (round 3): the composed URL row lives in the header under the title; its
649
- // LEAF segment carries the open-live affordance, mirroring the retired "View live"
650
- // button's gating EXACTLY. `href` present (published + frontendUrl) → the leaf opens
651
- // the live page in a new tab; absent → a muted, non-interactive icon with the same
652
- // reason tooltip (loading / publish-first / set-frontendUrl). The draft-derived path
653
- // itself still updates as you type — only the OPEN-live action is published-gated, so
654
- // the draft/live distinction is preserved.
655
- const headerLive = routableCollection
656
- ? liveUrl?.href
657
- ? { href: liveUrl.href, title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }) }
658
- : {
659
- title: publishedDocs.status === 'loading'
660
- ? translate('cms.editor.loadingLiveUrl')
661
- : !published
662
- ? translate('cms.editor.publishToViewLive')
663
- : translate('cms.editor.setFrontendUrlHint'),
664
- }
665
- : undefined;
438
+ // EC-205/EC-216 — the header composed-URL row's live-link descriptor. Derives
439
+ // strictly from PUBLISHED data (the pinned snapshot + published ancestors), never
440
+ // the draft, so an unpublished slug/parent edit can't leak into the live URL; the
441
+ // draft-derived path itself (rendered by `DocumentUrlPath`) still updates as you
442
+ // type — only the OPEN-live action is published-gated. `undefined` when the
443
+ // collection is non-routable.
444
+ const { headerLive } = useLiveUrl({ source, collection, doc, locale, published, routableCollection });
666
445
  // EC-240: hide the tab bar when there is nothing to navigate — a single content
667
446
  // tab with no References/Compare (the users rail). When framework tabs ARE
668
447
  // present we KEEP the content tab(s) so there is always a way back to the form;
@@ -672,14 +451,15 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
672
451
  const hasFrameworkTabs = showReferences || compare !== null;
673
452
  const showContentTabs = tabs.length > 1 || hasFrameworkTabs;
674
453
  const showLensRow = showContentTabs || canLocales || canPublish;
675
- // 0.0.39 split Publish button — the shared gating/label, computed once so the
676
- // primary "Publish" and the "Publish & close" item can never diverge.
677
- const publishDisabled = hasErrors || viewReadOnly || publishState === 'pending';
678
- const publishTitle = viewReadOnly
679
- ? translate('cms.editor.switchToDraftToPublish')
680
- : hasErrors
681
- ? translate(errorCount === 1 ? 'cms.editor.fixIssuesToPublish.one' : 'cms.editor.fixIssuesToPublish.other', { count: errorCount })
682
- : undefined;
454
+ // 0.0.39 split Publish button — the shared gate (disabled + title), computed once
455
+ // (pure `publishGate`) so the primary "Publish" and the "Publish & close" item can
456
+ // never diverge.
457
+ const { disabled: publishDisabled, title: publishTitle } = publishGate({
458
+ hasErrors,
459
+ viewReadOnly,
460
+ publishState,
461
+ errorCount,
462
+ });
683
463
  const publishButtonLabel = publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] }));
684
464
  // Offer "Publish & close" only when there IS a list to return to — the same gate
685
465
  // the removed EC-316 "Save & close" used (never a singleton or a list-less rail).
@@ -738,18 +518,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
738
518
  if (!open)
739
519
  setConfirmAction(null);
740
520
  }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
741
- setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open) {
742
- setPublishConfirm(false);
743
- closeAfterPublishRef.current = false;
744
- } }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
521
+ setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
522
+ cancelPublishConfirm(); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
745
523
  ? 'cms.editor.publishAlsoHasIssues.one'
746
- : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
747
- setPublishConfirm(false);
748
- closeAfterPublishRef.current = false;
749
- }, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
750
- setPublishConfirm(false);
751
- void doPublish();
752
- }, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
524
+ : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: cancelPublishConfirm, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: confirmPublish, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
753
525
  }
754
526
  /**
755
527
  * EC-300/AD-16 — errors for composition embeds (hard references) whose target isn't