canopycms 0.0.67 → 0.0.68-int.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +8 -8
- package/dist/api/branch-status.js +6 -2
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +35 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +38 -39
- package/dist/api/entries.js +6 -8
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/request-url.d.ts +8 -0
- package/dist/api/request-url.js +15 -0
- package/dist/api/resolve-references.js +2 -2
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +2 -2
- package/dist/cli/cli.js +285 -168
- package/dist/cli/generate-ai-content.js +190 -109
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +15 -10
- package/dist/cli/sync.js +15 -13
- package/dist/config/schemas/config.d.ts +6 -3
- package/dist/config/schemas/config.js +3 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/types.d.ts +11 -2
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +2 -2
- package/dist/content-listing.js +3 -3
- package/dist/content-reader.js +4 -4
- package/dist/content-store.d.ts +2 -0
- package/dist/content-store.js +2 -1
- package/dist/content-tree.js +1 -1
- package/dist/context.d.ts +16 -7
- package/dist/context.js +116 -67
- package/dist/editor/Editor.js +4 -4
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +37 -6
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/entry-schema-registry.js +2 -1
- package/dist/git-manager.d.ts +20 -0
- package/dist/git-manager.js +50 -4
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +24 -9
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +3 -3
- package/dist/reference-resolver.js +3 -2
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/services.d.ts +12 -5
- package/dist/services.js +56 -56
- package/dist/settings-workspace.js +36 -0
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.js +7 -8
- package/dist/types.d.ts +23 -2
- package/dist/utils/content-write-lock.d.ts +3 -7
- package/dist/utils/content-write-lock.js +6 -4
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +15 -0
- package/dist/utils/url-prefix.js +26 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/git-sync.d.ts +4 -2
- package/dist/worker/git-sync.js +111 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.js +75 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/context.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { isDeployedStatic, isBuildMode, STATIC_DEPLOY_USER } from './build-mode.js';
|
|
1
|
+
import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
|
|
2
2
|
import { createContentReader } from './content-reader.js';
|
|
3
3
|
import { ContentStoreError } from './content-store.js';
|
|
4
|
-
import { createLogicalPath, parseSlug, resolveBranchPaths } from './paths/index.js';
|
|
4
|
+
import { BranchPathError, createLogicalPath, parseSlug, resolveBranchPaths, } from './paths/index.js';
|
|
5
5
|
import { resolveUrlPathCandidates } from './url-path-resolver.js';
|
|
6
|
-
import { loadOrCreateBranchContext } from './branch-workspace.js';
|
|
6
|
+
import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
|
|
7
7
|
import { buildContentTree as buildContentTreeImpl, } from './content-tree.js';
|
|
8
8
|
import { listEntries as listEntriesImpl, } from './content-listing.js';
|
|
9
9
|
import { createDebugLogger } from './utils/debug.js';
|
|
@@ -22,6 +22,21 @@ function isLookupFailure(err) {
|
|
|
22
22
|
function isPageSwallowable(err) {
|
|
23
23
|
return isLookupFailure(err) || err.code === 'FORBIDDEN';
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A branch that already has a workspace, or null. A name the path layer
|
|
27
|
+
* rejects (a traversal segment) is null too: it arrives from the request, so it
|
|
28
|
+
* lists nothing rather than failing the page.
|
|
29
|
+
*/
|
|
30
|
+
async function loadExistingBranch(branchName, mode) {
|
|
31
|
+
try {
|
|
32
|
+
return await loadBranchContext({ branchName, mode });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err instanceof BranchPathError)
|
|
36
|
+
return null;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
25
40
|
/**
|
|
26
41
|
* Create a Canopy context managing auth + content reading. Framework-agnostic:
|
|
27
42
|
* the adapter supplies extractUser.
|
|
@@ -119,80 +134,114 @@ export function createCanopyContext(options) {
|
|
|
119
134
|
}
|
|
120
135
|
return null;
|
|
121
136
|
};
|
|
122
|
-
/** Resolve branch workspace and schema — shared by buildContentTree and listEntries. Memoized per getContext call. */
|
|
123
|
-
let schemaContextPromise = null;
|
|
124
|
-
const resolveSchemaContextImpl = async () => {
|
|
125
|
-
const operatingMode = services.config.mode;
|
|
126
|
-
const defaultBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
|
|
127
|
-
const branchContext = await loadOrCreateBranchContext({
|
|
128
|
-
config: services.config,
|
|
129
|
-
branchName: defaultBranch,
|
|
130
|
-
mode: operatingMode,
|
|
131
|
-
createdBy: 'canopycms-context',
|
|
132
|
-
remoteUrl: services.config.defaultRemoteUrl,
|
|
133
|
-
});
|
|
134
|
-
const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
|
|
135
|
-
const contentRootName = services.config.contentRoot || 'content';
|
|
136
|
-
const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
|
|
137
|
-
return { branchContext, branchRoot, flatSchema, contentRootName };
|
|
138
|
-
};
|
|
139
|
-
const resolveSchemaContext = () => {
|
|
140
|
-
if (!schemaContextPromise) {
|
|
141
|
-
schemaContextPromise = resolveSchemaContextImpl();
|
|
142
|
-
}
|
|
143
|
-
return schemaContextPromise;
|
|
144
|
-
};
|
|
145
137
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* disclose full entry `data` for paths the user cannot `read()` directly.
|
|
150
|
-
*
|
|
151
|
-
* `services.createContentAccessChecker` is the shared batch primitive
|
|
152
|
-
* (api/entries.ts uses it too): it resolves the request-constant work —
|
|
153
|
-
* branch access, the settings/permissions root, the rule set — once, and
|
|
154
|
-
* returns a synchronous per-path check, so the per-entry cost is an admin
|
|
155
|
-
* short-circuit or one minimatch per configured rule, with no extra I/O.
|
|
138
|
+
* What a batch read (listEntries / buildContentTree) lists from: the branch's
|
|
139
|
+
* schema plus its path-ACL predicate, or null when the branch yields nothing
|
|
140
|
+
* this user may see.
|
|
156
141
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* without this one every such script pays for a settings-workspace clone it
|
|
166
|
-
* never needed — and hard-fails where that workspace cannot be provisioned.
|
|
142
|
+
* With no `branch`, or the active branch named explicitly, it is the active
|
|
143
|
+
* branch, provisioned if missing. Any other name arrives from the request
|
|
144
|
+
* (an index page passing the preview iframe's `?branch=`), so it is
|
|
145
|
+
* load-only, never provisioned — a missing, non-string or traversal name
|
|
146
|
+
* lists nothing — and its branch access, held in its own metadata, is
|
|
147
|
+
* checked before its schema or content files are read. At
|
|
148
|
+
* build time and on static deployments `branch` selects nothing, as for
|
|
149
|
+
* `read` (see the module doc).
|
|
167
150
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* Deliberately NOT wrapped in try/catch — createContentAccessChecker is
|
|
173
|
-
* fail-loud by contract, and swallowing would serve an unfiltered listing.
|
|
151
|
+
* Memoized per getContext call and per branch name, on the in-flight
|
|
152
|
+
* promise; a null result is memoized too, so one request answers a given
|
|
153
|
+
* name consistently. The memo is request-scoped, so it carries no
|
|
154
|
+
* generation protocol (docs/concurrency.md, "Adding a call-scoped memo").
|
|
174
155
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
156
|
+
const listingSources = new Map();
|
|
157
|
+
// Read once, so every listing in this request agrees on it even if a
|
|
158
|
+
// concurrent request's refreshActiveBranch() replaces services.config.
|
|
159
|
+
const activeBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
|
|
160
|
+
const resolveListingSource = (branch) => {
|
|
161
|
+
// Typed `string`, but usually handed over from untyped `searchParams`,
|
|
162
|
+
// where a repeated `?branch=` arrives as an array.
|
|
163
|
+
if (branch !== undefined && typeof branch !== 'string')
|
|
164
|
+
return Promise.resolve(null);
|
|
165
|
+
const requested = branch && branch !== activeBranch && !readsFromCheckout(services.config)
|
|
166
|
+
? branch
|
|
167
|
+
: undefined;
|
|
168
|
+
const key = requested ?? activeBranch;
|
|
169
|
+
let source = listingSources.get(key);
|
|
170
|
+
if (!source) {
|
|
171
|
+
source = resolveListingSourceImpl(requested);
|
|
172
|
+
listingSources.set(key, source);
|
|
173
|
+
}
|
|
174
|
+
return source;
|
|
182
175
|
};
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
176
|
+
const resolveListingSourceImpl = async (requested) => {
|
|
177
|
+
const operatingMode = services.config.mode;
|
|
178
|
+
const branchContext = requested === undefined
|
|
179
|
+
? await loadOrCreateBranchContext({
|
|
180
|
+
config: services.config,
|
|
181
|
+
branchName: activeBranch,
|
|
182
|
+
mode: operatingMode,
|
|
183
|
+
createdBy: 'canopycms-context',
|
|
184
|
+
remoteUrl: services.config.defaultRemoteUrl,
|
|
185
|
+
})
|
|
186
|
+
: await loadExistingBranch(requested, operatingMode);
|
|
187
|
+
if (!branchContext)
|
|
188
|
+
return null;
|
|
189
|
+
// The path-ACL predicate keeps an unfiltered listing on this ACL-enforcing
|
|
190
|
+
// context from disclosing entry `data` for paths the user cannot `read()`.
|
|
191
|
+
// `services.createContentAccessChecker` is the shared batch primitive
|
|
192
|
+
// (api/entries.ts uses it too): it resolves the request-constant work —
|
|
193
|
+
// branch access, the settings/permissions root, the rule set — once, and
|
|
194
|
+
// returns a synchronous per-path check, so the per-entry cost is an admin
|
|
195
|
+
// short-circuit or one minimatch per configured rule, with no extra I/O.
|
|
196
|
+
//
|
|
197
|
+
// No predicate (unfiltered) at build time, on static deployments, and for
|
|
198
|
+
// the synthetic admin is load-bearing, not an optimization. That user has
|
|
199
|
+
// unconditional access anyway (path checks bypass entirely for an
|
|
200
|
+
// Admins-group user — authorization/path.ts), but BUILDING the checker costs
|
|
201
|
+
// a getSettingsBranchRoot() call, which in modes with a separate settings
|
|
202
|
+
// branch provisions that branch's git workspace: an EFS round trip in prod.
|
|
203
|
+
// `createBuildCanopy` (build-canopy.ts) runs outside a request or Next.js
|
|
204
|
+
// build phase, so neither other guard fires for it, and without this one
|
|
205
|
+
// every such script pays for a settings-workspace clone it never needed —
|
|
206
|
+
// and hard-fails where that workspace cannot be provisioned. Compared by
|
|
207
|
+
// reference to the STATIC_DEPLOY_USER singleton, not by group membership,
|
|
208
|
+
// so a real authenticated admin at request time still goes through the
|
|
209
|
+
// real check.
|
|
210
|
+
//
|
|
211
|
+
// Deliberately NOT wrapped in try/catch — createContentAccessChecker is
|
|
212
|
+
// fail-loud by contract, and swallowing would serve an unfiltered listing.
|
|
213
|
+
const enforceAcls = !(isDeployedStatic(services.config) ||
|
|
214
|
+
isBuildMode() ||
|
|
215
|
+
user === STATIC_DEPLOY_USER);
|
|
216
|
+
if (enforceAcls && !services.checkBranchAccess(branchContext, user).allowed) {
|
|
217
|
+
return null;
|
|
186
218
|
}
|
|
187
|
-
|
|
219
|
+
const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
|
|
220
|
+
const contentRootName = services.config.contentRoot || 'content';
|
|
221
|
+
const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
|
|
222
|
+
if (!enforceAcls)
|
|
223
|
+
return { branchRoot, flatSchema, contentRootName, visibility: {} };
|
|
224
|
+
const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
|
|
225
|
+
return {
|
|
226
|
+
branchRoot,
|
|
227
|
+
flatSchema,
|
|
228
|
+
contentRootName,
|
|
229
|
+
visibility: { shouldInclude: (logicalPath) => checkAccess(logicalPath, 'read').allowed },
|
|
230
|
+
};
|
|
188
231
|
};
|
|
189
232
|
const buildContentTree = async (options) => {
|
|
190
|
-
const {
|
|
191
|
-
|
|
233
|
+
const { branch, ...treeOptions } = options ?? {};
|
|
234
|
+
const source = await resolveListingSource(branch);
|
|
235
|
+
if (!source)
|
|
236
|
+
return [];
|
|
237
|
+
return buildContentTreeImpl(source.branchRoot, source.flatSchema, source.contentRootName, treeOptions, source.visibility);
|
|
192
238
|
};
|
|
193
239
|
const listEntries = async (options) => {
|
|
194
|
-
const {
|
|
195
|
-
|
|
240
|
+
const { branch, ...listOptions } = options ?? {};
|
|
241
|
+
const source = await resolveListingSource(branch);
|
|
242
|
+
if (!source)
|
|
243
|
+
return [];
|
|
244
|
+
return listEntriesImpl(source.branchRoot, source.flatSchema, source.contentRootName, listOptions, source.visibility);
|
|
196
245
|
};
|
|
197
246
|
return {
|
|
198
247
|
read,
|
package/dist/editor/Editor.js
CHANGED
|
@@ -24,6 +24,7 @@ import { EditorFooter, EditorHeader, EditorSidebar } from './components/index.js
|
|
|
24
24
|
import { RenameEntryModal } from './components/RenameEntryModal.js';
|
|
25
25
|
import { EntryCreateModal } from './components/EntryCreateModal.js';
|
|
26
26
|
import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
|
|
27
|
+
import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
|
|
27
28
|
import { CollectionEditor } from './schema-editor/index.js';
|
|
28
29
|
import { AssetContextProvider, useApiClient } from './context/index.js';
|
|
29
30
|
import { EntryLinkContext } from './fields/entry-link/index.js';
|
|
@@ -224,9 +225,8 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
224
225
|
// branch's content. Writing it would (a) show it as the new branch's
|
|
225
226
|
// content and (b) satisfy the skip gate above, permanently suppressing
|
|
226
227
|
// the new branch's own load -- which also leaves the new branch's OCC
|
|
227
|
-
// token unset,
|
|
228
|
-
//
|
|
229
|
-
// under its own key.
|
|
228
|
+
// token unset, so saveEntry refuses every save of the entry. Drop it
|
|
229
|
+
// instead; the new branch has its own load running under its own key.
|
|
230
230
|
if (currentBranchRef.current !== requestBranch)
|
|
231
231
|
return;
|
|
232
232
|
setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
|
|
@@ -644,7 +644,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
644
644
|
? 'Setting up your branch workspace…'
|
|
645
645
|
: entriesInitializing
|
|
646
646
|
? 'Loading content…'
|
|
647
|
-
: 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(
|
|
647
|
+
: 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
|
|
648
648
|
const contentId = currentEntry?.contentId;
|
|
649
649
|
if (contentId) {
|
|
650
650
|
setDrafts((prev) => ({ ...prev, [contentId]: next }));
|
|
@@ -20,6 +20,18 @@ import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
|
|
|
20
20
|
import { FieldWrapper } from './comments/FieldWrapper.js';
|
|
21
21
|
import { EntryComments } from './comments/EntryComments.js';
|
|
22
22
|
import { useReferenceResolution } from './hooks/useReferenceResolution.js';
|
|
23
|
+
/**
|
|
24
|
+
* Card heading for an object-list item: the value of its `itemTitleField` when that is a
|
|
25
|
+
* non-blank string or a number, otherwise `fallback`.
|
|
26
|
+
*/
|
|
27
|
+
const listItemTitle = (item, itemTitleField, fallback) => {
|
|
28
|
+
const raw = itemTitleField ? item?.[itemTitleField] : undefined;
|
|
29
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
30
|
+
return String(raw);
|
|
31
|
+
if (typeof raw === 'string' && raw.trim() !== '')
|
|
32
|
+
return raw.trim();
|
|
33
|
+
return fallback;
|
|
34
|
+
};
|
|
23
35
|
const normalizeOptions = (options) => {
|
|
24
36
|
if (!options)
|
|
25
37
|
return [];
|
|
@@ -135,11 +147,14 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
135
147
|
const items = Array.isArray(currentValue)
|
|
136
148
|
? currentValue
|
|
137
149
|
: [];
|
|
138
|
-
return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) =>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
|
|
151
|
+
const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
|
|
152
|
+
return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
|
|
153
|
+
const nextItems = [...items];
|
|
154
|
+
nextItems[idx] = next;
|
|
155
|
+
update(nextItems);
|
|
156
|
+
}, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])));
|
|
157
|
+
}), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
|
|
143
158
|
}
|
|
144
159
|
// Unlike the list branch above (each item has its own Remove button), a
|
|
145
160
|
// non-list object has no other way back to `undefined`: a required child
|
|
@@ -59,7 +59,9 @@ function purgeGateFor(entry) {
|
|
|
59
59
|
}
|
|
60
60
|
return {
|
|
61
61
|
disabled: lockFresh,
|
|
62
|
-
tooltip: lockFresh
|
|
62
|
+
tooltip: lockFresh
|
|
63
|
+
? 'Provisioning, or the worker syncing this branch, may be in progress'
|
|
64
|
+
: undefined,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
function workerLivenessBadge(worker, mode) {
|
|
@@ -76,6 +78,33 @@ function workerLivenessBadge(worker, mode) {
|
|
|
76
78
|
return { color: 'red', label: 'Worker: absent' };
|
|
77
79
|
}
|
|
78
80
|
}
|
|
81
|
+
const BASE_REFRESH_LABELS = {
|
|
82
|
+
refreshed: 'fast-forwarded',
|
|
83
|
+
'up-to-date': 'up to date',
|
|
84
|
+
'skipped-dirty': 'refresh skipped (uncommitted changes)',
|
|
85
|
+
'skipped-locked': 'refresh skipped (workspace busy: provisioning or an admin action)',
|
|
86
|
+
'skipped-not-provisioned': 'not yet provisioned',
|
|
87
|
+
failed: 'refresh failed',
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Why the base branch needs an operator, or null when its last refresh needs
|
|
91
|
+
* nothing. Shared by the overview and the base row's warning tooltip.
|
|
92
|
+
*/
|
|
93
|
+
function baseRefreshWarning(report) {
|
|
94
|
+
if (!report)
|
|
95
|
+
return null;
|
|
96
|
+
const lines = [];
|
|
97
|
+
if (report.outcome === 'skipped-dirty' || report.outcome === 'failed') {
|
|
98
|
+
lines.push(`Base branch ${BASE_REFRESH_LABELS[report.outcome]}${report.message ? `: ${report.message}` : ''}`);
|
|
99
|
+
if (report.dirtyFiles?.length)
|
|
100
|
+
lines.push(`Uncommitted: ${report.dirtyFiles.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
if (report.trackedCanopyMeta?.length) {
|
|
103
|
+
lines.push(`The site repo tracks canopycms state (${report.trackedCanopyMeta.join(', ')}). ` +
|
|
104
|
+
'Untrack it with `git rm -r --cached .canopy-meta`, add `.canopy-meta/` to .gitignore, and commit.');
|
|
105
|
+
}
|
|
106
|
+
return lines.length > 0 ? lines.join('\n') : null;
|
|
107
|
+
}
|
|
79
108
|
// Mirrors BranchManager.tsx's statusColorMap -- kept local (not exported
|
|
80
109
|
// there) rather than shared, same tiny lookup either way.
|
|
81
110
|
const branchStatusColorMap = {
|
|
@@ -121,9 +150,10 @@ function OverviewTab({ health }) {
|
|
|
121
150
|
const liveness = workerLivenessBadge(status.worker, status.mode);
|
|
122
151
|
const lastFatalError = status.workerStatus?.lastFatalError;
|
|
123
152
|
const lastGitSync = status.workerStatus?.lastGitSync;
|
|
153
|
+
const baseWarning = baseRefreshWarning(lastGitSync?.baseRefresh);
|
|
124
154
|
return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
|
|
125
|
-
? ` · ${lastGitSync.skippedLocked.length} skipped (content write
|
|
126
|
-
: ''] }), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
|
|
155
|
+
? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
|
|
156
|
+
: ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
|
|
127
157
|
}
|
|
128
158
|
function TasksTab({ health }) {
|
|
129
159
|
const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
|
|
@@ -160,6 +190,7 @@ function TasksTab({ health }) {
|
|
|
160
190
|
function BranchesTab({ health }) {
|
|
161
191
|
const { branchHealth, branchHealthLoading } = health;
|
|
162
192
|
const entries = branchHealth?.entries ?? [];
|
|
193
|
+
const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
|
|
163
194
|
const handleMarkMergedClick = (branchName) => {
|
|
164
195
|
modals.openConfirmModal({
|
|
165
196
|
title: 'Mark branch as merged',
|
|
@@ -193,9 +224,9 @@ function BranchesTab({ health }) {
|
|
|
193
224
|
if (entries.length === 0) {
|
|
194
225
|
return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
|
|
195
226
|
}
|
|
196
|
-
return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
|
|
227
|
+
return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
|
|
197
228
|
}
|
|
198
|
-
function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
|
|
229
|
+
function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
|
|
199
230
|
if (entry.kind === 'healthy' && entry.branch) {
|
|
200
231
|
const b = entry.branch;
|
|
201
232
|
// Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
|
|
@@ -207,7 +238,7 @@ function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
|
|
|
207
238
|
const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
|
|
208
239
|
return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusColorMap[b.status] ?? 'neutral', variant: "light", children: b.status }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(Tooltip, { label: b.syncFailureReason
|
|
209
240
|
? `${b.syncFailureReason} — retry from the Tasks tab`
|
|
210
|
-
: 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
|
|
241
|
+
: 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(Tooltip, { label: baseWarning, multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
|
|
211
242
|
.map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
|
|
212
243
|
.join('\n'), multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `duplicate-content-ids-${entry.dirName}`, children: [entry.duplicateContentIds.length, " duplicate ID", entry.duplicateContentIds.length === 1 ? '' : 's'] }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
|
|
213
244
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface NoEditPermissionNoticeProps {
|
|
2
|
+
/** Logical path of the entry the user cannot edit. */
|
|
3
|
+
entryPath: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
|
|
7
|
+
* when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
|
|
8
|
+
* locked or protected branch shows its own banner in the header and a read-only form instead.
|
|
9
|
+
*/
|
|
10
|
+
export declare function NoEditPermissionNotice({ entryPath }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from '@mantine/core';
|
|
3
|
+
/**
|
|
4
|
+
* Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
|
|
5
|
+
* when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
|
|
6
|
+
* locked or protected branch shows its own banner in the header and a read-only form instead.
|
|
7
|
+
*/
|
|
8
|
+
export function NoEditPermissionNotice({ entryPath }) {
|
|
9
|
+
return (_jsx("div", { role: "status", "data-testid": "no-edit-permission-notice", style: {
|
|
10
|
+
display: 'flex',
|
|
11
|
+
alignItems: 'center',
|
|
12
|
+
justifyContent: 'center',
|
|
13
|
+
height: '100%',
|
|
14
|
+
padding: '0 var(--mantine-spacing-md)',
|
|
15
|
+
textAlign: 'center',
|
|
16
|
+
}, children: _jsxs(Text, { size: "sm", c: "dimmed", children: ["You don't have edit access to \"", entryPath, "\". Ask a CanopyCMS admin to grant it in Manage Permissions."] }) }));
|
|
17
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* `mutate()` to revalidate.
|
|
14
14
|
*/
|
|
15
15
|
import useSWR from 'swr';
|
|
16
|
+
import { isNonApiResponse } from '../../api/client.js';
|
|
16
17
|
/** Cache key for the branches list. */
|
|
17
18
|
export const BRANCHES_KEY = 'canopy:branches';
|
|
18
19
|
/**
|
|
@@ -21,9 +22,10 @@ export const BRANCHES_KEY = 'canopy:branches';
|
|
|
21
22
|
*/
|
|
22
23
|
export async function fetchBranches(apiClient) {
|
|
23
24
|
const result = await apiClient.branches.list();
|
|
24
|
-
if (result.status === 404) {
|
|
25
|
+
if (result.status === 404 && !isNonApiResponse(result)) {
|
|
25
26
|
// No branch endpoint available; stay branchless rather than erroring --
|
|
26
27
|
// the branch dropdown stays clickable so the user can retry from there.
|
|
28
|
+
// A proxy's 404 page means the API was not reached, so it is an error.
|
|
27
29
|
return { branches: [] };
|
|
28
30
|
}
|
|
29
31
|
if (!result.ok) {
|
|
@@ -14,7 +14,7 @@ export interface UseDraftManagerOptions {
|
|
|
14
14
|
* shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
|
|
15
15
|
* version it was based on, and detects at save time that the token has
|
|
16
16
|
* since moved on. Optional: without it no base versions are recorded and
|
|
17
|
-
* conflict detection falls back entirely to
|
|
17
|
+
* conflict detection falls back entirely to saveEntry's token check.
|
|
18
18
|
*/
|
|
19
19
|
getEntryVersion?: (contentId: string) => number | undefined;
|
|
20
20
|
setBusy: (busy: boolean) => void;
|
|
@@ -316,7 +316,8 @@ export function useDraftManager(options) {
|
|
|
316
316
|
return 'ok';
|
|
317
317
|
const currentVersion = options.getEntryVersion?.(currentId);
|
|
318
318
|
// No server version known for this entry at all -- nothing to compare
|
|
319
|
-
// against, so this check has no opinion
|
|
319
|
+
// against, so this check has no opinion: saveEntry refuses a save without
|
|
320
|
+
// a token, and the server refuses a version-less update.
|
|
320
321
|
if (currentVersion === undefined)
|
|
321
322
|
return 'ok';
|
|
322
323
|
const base = draftBaseVersionsRef.current[currentId];
|
|
@@ -40,7 +40,7 @@ export async function listAllEntries(apiClient, branch) {
|
|
|
40
40
|
...(cursor !== undefined ? { cursor } : {}),
|
|
41
41
|
});
|
|
42
42
|
if (!result.ok || !result.data)
|
|
43
|
-
throw new Error(`Refresh failed: ${result.status}`);
|
|
43
|
+
throw new Error(`Refresh failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
|
|
44
44
|
const data = result.data;
|
|
45
45
|
for (const entry of data.entries)
|
|
46
46
|
byPath.set(entry.logicalPath, entry);
|
|
@@ -60,7 +60,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
|
|
|
60
60
|
// Fetch schema from schema API
|
|
61
61
|
const schemaResult = await apiClient.schema.get({ branch });
|
|
62
62
|
if (!schemaResult.ok || !schemaResult.data) {
|
|
63
|
-
throw new Error(`Schema fetch failed: ${schemaResult.status}`);
|
|
63
|
+
throw new Error(`Schema fetch failed: ${schemaResult.status}${schemaResult.error ? ` — ${schemaResult.error}` : ''}`);
|
|
64
64
|
}
|
|
65
65
|
// Hydrate wire flatSchema: resolve schemaRef -> schema from entrySchemas dict
|
|
66
66
|
const { entrySchemas } = schemaResult.data;
|
|
@@ -50,11 +50,9 @@ export function useEntryManager(options) {
|
|
|
50
50
|
// case -- any first visit to a branch, for the whole duration of its fetch),
|
|
51
51
|
// or a stale SWR slot. The editor then auto-selected one of those stale
|
|
52
52
|
// entries (see the selection effect below), and a save could file its OCC
|
|
53
|
-
// token under one contentId and look it up under another
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// Deriving from a stamped record fixes that structurally instead of relying
|
|
57
|
-
// on every code path remembering to clear.
|
|
53
|
+
// token under one contentId and look it up under another. Deriving from a
|
|
54
|
+
// stamped record fixes that structurally instead of relying on every code
|
|
55
|
+
// path remembering to clear.
|
|
58
56
|
const [view, setView] = useState(() => ({
|
|
59
57
|
branch: options.branchName,
|
|
60
58
|
entries: options.initialEntries,
|
|
@@ -86,6 +84,9 @@ export function useEntryManager(options) {
|
|
|
86
84
|
// branch can repopulate the map after the branch-change clear() below and
|
|
87
85
|
// poison the next save with the old branch's mtime — a deterministic 409
|
|
88
86
|
// ("modified by another editor") on save-after-switch, proven by e2e trace.
|
|
87
|
+
// The contentId half matches how useDraftManager keys drafts and loaded
|
|
88
|
+
// values, so a form value and its token always belong to the same file even
|
|
89
|
+
// when a path is deleted and recreated; it also survives a rename.
|
|
89
90
|
const entryVersionsRef = useRef(new Map());
|
|
90
91
|
const versionKey = (branch, contentId) => `${branch}:${contentId}`;
|
|
91
92
|
// PER-BRANCH monotonic tokens guarding every commit of the fetched
|
|
@@ -192,7 +193,7 @@ export function useEntryManager(options) {
|
|
|
192
193
|
path,
|
|
193
194
|
});
|
|
194
195
|
if (!result.ok)
|
|
195
|
-
throw new Error(`Load failed: ${result.status}`);
|
|
196
|
+
throw new Error(`Load failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
|
|
196
197
|
// Capture OCC version token for next save
|
|
197
198
|
if (entry.contentId && typeof result.data?.version === 'number') {
|
|
198
199
|
entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
|
|
@@ -220,18 +221,23 @@ export function useEntryManager(options) {
|
|
|
220
221
|
};
|
|
221
222
|
if (entry.entryType)
|
|
222
223
|
writeParams.entryType = entry.entryType;
|
|
223
|
-
const expectedVersion = entry.contentId
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
const expectedVersion = entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId));
|
|
225
|
+
// Every entry saved here already exists (creates go through handleCreateModalSubmit), so
|
|
226
|
+
// no token means this entry was never successfully read on this branch and nothing could check the save
|
|
227
|
+
// against other editors' work. Refused as a conflict; the server refuses it too.
|
|
228
|
+
if (expectedVersion === undefined) {
|
|
229
|
+
throw new SaveApiError(409, 'This entry has not been loaded from the server, so the save cannot be checked ' +
|
|
230
|
+
"against other editors' changes. Reload it and try again.");
|
|
231
|
+
}
|
|
226
232
|
const writeBody = {
|
|
227
233
|
...payload,
|
|
228
|
-
|
|
234
|
+
expectedVersion,
|
|
229
235
|
};
|
|
230
236
|
const result = await apiClient.content.write(writeParams, writeBody);
|
|
231
237
|
if (!result.ok)
|
|
232
238
|
throw new SaveApiError(result.status, result.error, result.fieldErrors);
|
|
233
239
|
// Update stored version token from write response
|
|
234
|
-
if (
|
|
240
|
+
if (typeof result.data?.version === 'number') {
|
|
235
241
|
entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
|
|
236
242
|
}
|
|
237
243
|
// Warning-level issues from the adopter's validateEntry hook: saved, but surface them
|
|
@@ -2,7 +2,7 @@ import { countTitleFields, findInvalidTitleFields, findTitleFieldsInLists, } fro
|
|
|
2
2
|
import { countBodyFields, findInvalidBodyFields, findReservedBodyFieldName, } from './utils/body-field.js';
|
|
3
3
|
import { RESOLVED_REFERENCE_KEYS } from './entry-schema.js';
|
|
4
4
|
import { flattenGroupFields } from './utils/flatten-group-fields.js';
|
|
5
|
-
import { ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, } from './config/validation.js';
|
|
5
|
+
import { ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureItemTitleFieldsExist, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, } from './config/validation.js';
|
|
6
6
|
/** Look up a field's type by dotted path (e.g., "meta.order").
|
|
7
7
|
* Groups are transparent — their children are searched at the same path level. */
|
|
8
8
|
function findFieldType(fields, dottedPath) {
|
|
@@ -94,6 +94,7 @@ export function createEntrySchemaRegistry(registry) {
|
|
|
94
94
|
}
|
|
95
95
|
ensureSelectFieldsHaveOptions(schema);
|
|
96
96
|
ensureReferenceFieldsHaveScope(schema);
|
|
97
|
+
ensureItemTitleFieldsExist(schema);
|
|
97
98
|
ensureNoGroupsInsideComplexFields(schema);
|
|
98
99
|
ensureNoFlattenedFieldNameCollisions(schema, `entry schema "${key}"`);
|
|
99
100
|
}
|
package/dist/git-manager.d.ts
CHANGED
|
@@ -69,6 +69,17 @@ export declare class GitRemoteRefMissingError extends Error {
|
|
|
69
69
|
*/
|
|
70
70
|
gitError?: unknown | undefined);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* No git remote is configured and the one the strategy auto-detects does not
|
|
74
|
+
* exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
|
|
75
|
+
* its first boot, so until then every request that needs a workspace fails
|
|
76
|
+
* here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
|
|
77
|
+
* The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
|
|
78
|
+
*/
|
|
79
|
+
export declare class RemoteNotReadyError extends Error {
|
|
80
|
+
readonly expectedRemotePath: string;
|
|
81
|
+
constructor(expectedRemotePath: string);
|
|
82
|
+
}
|
|
72
83
|
export interface ResolveRemoteUrlOptions {
|
|
73
84
|
mode: OperatingMode;
|
|
74
85
|
remoteUrl?: string;
|
|
@@ -283,6 +294,8 @@ export declare class GitManager {
|
|
|
283
294
|
rebaseOntoBase(): Promise<void>;
|
|
284
295
|
private rebaseOntoBaseInner;
|
|
285
296
|
add(files: string | string[]): Promise<void>;
|
|
297
|
+
/** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
|
|
298
|
+
addAllExceptCanopyState(): Promise<void>;
|
|
286
299
|
commit(message: string): Promise<void>;
|
|
287
300
|
push(branch?: string): Promise<void>;
|
|
288
301
|
/**
|
|
@@ -303,6 +316,13 @@ export declare class GitManager {
|
|
|
303
316
|
* ("couldn't find remote ref"); that counts as "ahead", not an error.
|
|
304
317
|
*/
|
|
305
318
|
hasUnpushedCommits(branch?: string): Promise<boolean>;
|
|
319
|
+
/**
|
|
320
|
+
* Repo-relative paths that differ between HEAD and its merge base with the
|
|
321
|
+
* base branch: everything the branch changes, across all of its commits.
|
|
322
|
+
* Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
|
|
323
|
+
* constraint.
|
|
324
|
+
*/
|
|
325
|
+
listChangedPathsSinceBase(): Promise<string[]>;
|
|
306
326
|
ensureAuthor(author: {
|
|
307
327
|
name: string;
|
|
308
328
|
email: string;
|