canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -70,11 +70,11 @@ export async function buildContentTree(branchRoot, flatSchema, contentRootName,
70
70
  // read once for the entire tree rather than once per collection. Null unless opted in —
71
71
  // see the `resolveReferences` option above.
72
72
  const resolver = options?.resolveReferences
73
- ? createReferenceResolver(branchRoot, flatSchema, contentRootName)
73
+ ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility)
74
74
  : null;
75
75
  const listVisibleEntries = async (collection) => {
76
76
  const entries = await listCollectionEntries(branchRoot, collection);
77
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
77
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
78
78
  // Resolved here rather than at the two node-building sites so BOTH inherit it — the same
79
79
  // reason the ACL filter lives here. A denied entry is filtered above and never resolved.
80
80
  return resolver ? resolveCollectionItemReferences(visible, collection, resolver) : visible;
package/dist/context.d.ts CHANGED
@@ -15,6 +15,10 @@
15
15
  * Only request-time reads on a server deployment resolve a branch workspace —
16
16
  * in dev, the clone under `.canopy-dev/content-branches/`.
17
17
  *
18
+ * A `branch` arrives from the request (a page passing `?branch=`), so only the
19
+ * active branch is ever provisioned here; any other name must already exist and
20
+ * pass the user's branch access before its files are read (`resolveBranch`).
21
+ *
18
22
  * The Next.js adapter wraps this in React `cache()` for per-request memoization
19
23
  * (`canopycms-next/src/context-wrapper.ts`).
20
24
  *
@@ -58,8 +62,12 @@ export interface CanopyBuildContext {
58
62
  * `meta.indexEntry` handed to `extract`, and a collection whose children are
59
63
  * all filtered out is pruned. On the build context and on static deployments
60
64
  * nothing is filtered — the synthetic admin sees everything.
65
+ *
66
+ * Branch: the `branch` option behaves as on `listEntries`.
61
67
  */
62
- buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes>) => Promise<ContentTreeNode<T>[]>;
68
+ buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes> & {
69
+ branch?: string;
70
+ }) => Promise<ContentTreeNode<T>[]>;
63
71
  /**
64
72
  * Every content entry as a flat array.
65
73
  *
@@ -67,13 +75,18 @@ export interface CanopyBuildContext {
67
75
  * cannot `read` are omitted before `extract` runs; on the build context and
68
76
  * static deployments nothing is filtered.
69
77
  *
70
- * Branch: unlike `read`/`readByUrlPath` this takes no `branch` option, always
71
- * listing `defaultActiveBranch ?? defaultBaseBranch ?? 'main'`. In `dev` that
72
- * tracks git HEAD via `refreshActiveBranch()`; in `prod` the refresh is a
73
- * no-op, so it always reads the base branch. See
74
- * `.claude/future-tasks/context-listing-branch-pinning.md`.
78
+ * Branch: with no `branch` it lists the active branch
79
+ * (`defaultActiveBranch ?? defaultBaseBranch`): git HEAD in `dev` when that is
80
+ * unset, otherwise usually the base branch in `prod`. Pass the preview
81
+ * iframe's `?branch=` as `branch`, as for `read`, so an index page previewed
82
+ * on a content branch lists that branch. Any other branch must already exist
83
+ * and, on the request-scoped context, be readable by the user; otherwise, or
84
+ * for an array, the result is empty. It selects nothing at build
85
+ * time or on static deployments.
75
86
  */
76
- listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T>) => Promise<ListEntriesItem<T>[]>;
87
+ listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T> & {
88
+ branch?: string;
89
+ }) => Promise<ListEntriesItem<T>[]>;
77
90
  /**
78
91
  * Content reader, with the auth context applied automatically (admin at build
79
92
  * time).
@@ -83,6 +96,11 @@ export interface CanopyBuildContext {
83
96
  * output, since it reveals the deployment's filesystem layout (home dir, EFS
84
97
  * mount, branch name). It is for build-time reads of colocated artifacts, e.g.
85
98
  * `fs.readFile(path.join(path.dirname(result.meta.physicalPath), 'profile.json'))`.
99
+ *
100
+ * Branch: with no `branch` it reads the active branch, provisioning its workspace if missing.
101
+ * Any other branch must already exist and, on the request-scoped context, be readable by the
102
+ * user; otherwise, or for an array, it throws NOT_FOUND, so a hidden branch reads as a missing
103
+ * one. It selects nothing at build time or on static deployments.
86
104
  */
87
105
  read: <T = unknown>(input: {
88
106
  entryPath: string;
@@ -109,8 +127,8 @@ export interface CanopyBuildContext {
109
127
  *
110
128
  * Null when nothing matches — a collection URL with no index entry (use
111
129
  * buildContentTree), a non-entry path like `/favicon.ico` the slug validator
112
- * rejects, or a path the user may not read, so a FORBIDDEN denial renders as
113
- * a 404 via `notFound()` rather than a 500. Strict `read()` still throws.
130
+ * rejects, a missing branch, or a path or branch the user may not read, so a
131
+ * denial renders as a 404 via `notFound()`, not a 500. Strict `read()` throws.
114
132
  *
115
133
  * `meta.physicalPath` is **server-only**, for the reason given on `read`.
116
134
  * `meta.entryType`/`entryId` come free with path resolution, so one catch-all
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 { createLogicalPath, namesNoWorkspace, 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,30 @@ function isLookupFailure(err) {
22
22
  function isPageSwallowable(err) {
23
23
  return isLookupFailure(err) || err.code === 'FORBIDDEN';
24
24
  }
25
+ /**
26
+ * A `branch` option is typed `string` but usually handed over from untyped
27
+ * request input: a repeated `?branch=` arrives as an array, which names no
28
+ * branch, and `URLSearchParams.get` gives null for an absent one, which is no
29
+ * `branch` at all.
30
+ */
31
+ function isBranchOption(branch) {
32
+ return branch == null || typeof branch === 'string';
33
+ }
34
+ /**
35
+ * A branch that already has a workspace, or null. A name that cannot name one
36
+ * is null too (`namesNoWorkspace`): it arrives from the request, so it reads as
37
+ * not-found rather than failing the page.
38
+ */
39
+ async function loadExistingBranch(branchName, mode) {
40
+ try {
41
+ return await loadBranchContext({ branchName, mode });
42
+ }
43
+ catch (err) {
44
+ if (namesNoWorkspace(err))
45
+ return null;
46
+ throw err;
47
+ }
48
+ }
25
49
  /**
26
50
  * Create a Canopy context managing auth + content reading. Framework-agnostic:
27
51
  * the adapter supplies extractUser.
@@ -46,7 +70,66 @@ export function createCanopyContext(options) {
46
70
  // switching branches mid-session updates what getCanopy() serves.
47
71
  await services.refreshActiveBranch();
48
72
  const user = await getUser();
49
- const baseReader = createContentReader({ services });
73
+ // Read once, so every read and listing in this request agrees on it even if
74
+ // a concurrent request's refreshActiveBranch() replaces services.config.
75
+ const activeBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
76
+ // No ACL enforcement at build time, on static deployments, or for the
77
+ // synthetic admin. For the last this is load-bearing, not an optimization:
78
+ // that user has unconditional access anyway (path checks bypass entirely for
79
+ // an Admins-group user — authorization/path.ts), but BUILDING a path checker
80
+ // costs a getSettingsBranchRoot() call, which in modes with a separate
81
+ // settings branch provisions that branch's git workspace: an EFS round trip
82
+ // in prod. `createBuildCanopy` (build-canopy.ts) runs outside a request or
83
+ // Next.js build phase, so neither other guard fires for it, and without this
84
+ // one every such script pays for a settings-workspace clone it never needed —
85
+ // and hard-fails where that workspace cannot be provisioned. Compared by
86
+ // reference to the STATIC_DEPLOY_USER singleton, not by group membership, so
87
+ // a real authenticated admin at request time still goes through the real
88
+ // check.
89
+ const enforceAcls = !(isDeployedStatic(services.config) ||
90
+ isBuildMode() ||
91
+ user === STATIC_DEPLOY_USER);
92
+ /**
93
+ * `branch` when it names a branch other than the active one, else undefined:
94
+ * no `branch`, an empty one, the active one named explicitly, or any name
95
+ * when reading from the checkout, where it selects nothing.
96
+ */
97
+ const otherBranch = (branch) => branch && branch !== activeBranch && !readsFromCheckout(services.config) ? branch : undefined;
98
+ /**
99
+ * The workspace for the active branch (undefined) or another named one, or
100
+ * null when it yields nothing this user may see.
101
+ *
102
+ * The active branch is provisioned if missing. Any other name is load-only,
103
+ * never provisioned — a name with no workspace is null (`loadExistingBranch`) — and its branch
104
+ * access, held in its own metadata, is checked here, before its schema or
105
+ * content files are read, so a denied branch is indistinguishable from a
106
+ * missing one. The active branch's access is left to the caller: a listing
107
+ * of it lists nothing, and a strict `read` of it throws FORBIDDEN.
108
+ */
109
+ const resolveBranch = async (requested) => {
110
+ if (requested === undefined) {
111
+ return loadOrCreateBranchContext({
112
+ config: services.config,
113
+ branchName: activeBranch,
114
+ mode: services.config.mode,
115
+ createdBy: 'canopycms-context',
116
+ remoteUrl: services.config.defaultRemoteUrl,
117
+ });
118
+ }
119
+ const context = await loadExistingBranch(requested, services.config.mode);
120
+ if (!context)
121
+ return null;
122
+ if (enforceAcls && !services.checkBranchAccess(context, user).allowed)
123
+ return null;
124
+ return context;
125
+ };
126
+ // The reader resolves every branch through resolveBranch; a null reads as
127
+ // NOT_FOUND. Reads from the checkout bypass the resolver inside the reader.
128
+ const baseReader = createContentReader({
129
+ services,
130
+ defaultBranch: activeBranch,
131
+ getBranchContext: (branchName) => resolveBranch(otherBranch(branchName)),
132
+ });
50
133
  // Injects the user and validates strings → branded types at this boundary.
51
134
  // `extra` carries options that are NOT part of the public `read` surface —
52
135
  // today just readByUrlPath's URL-addressability gate. A separate inner
@@ -63,10 +146,13 @@ export function createCanopyContext(options) {
63
146
  }
64
147
  slug = slugResult.slug;
65
148
  }
149
+ if (!isBranchOption(input.branch)) {
150
+ throw new ContentStoreError(`Branch not found: ${String(input.branch)}`, 'NOT_FOUND');
151
+ }
66
152
  const readInput = {
67
153
  entryPath,
68
154
  slug,
69
- branch: input.branch,
155
+ branch: input.branch ?? undefined,
70
156
  user,
71
157
  resolveReferences: input.resolveReferences ?? true,
72
158
  ...extra,
@@ -119,80 +205,78 @@ export function createCanopyContext(options) {
119
205
  }
120
206
  return null;
121
207
  };
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
208
  /**
146
- * Path-ACL predicate for the batch reads (listEntries / buildContentTree),
147
- * memoized per getContext call like the schema context above. Without it an
148
- * unfiltered listing on this request-scoped, ACL-enforcing context would
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.
156
- *
157
- * The empty object (no predicate → unfiltered) at build time, on static
158
- * deployments, and for the synthetic admin is load-bearing, not an
159
- * optimization. That user has unconditional access anyway (path checks
160
- * bypass entirely for an Admins-group user — authorization/path.ts), but
161
- * BUILDING the checker costs a getSettingsBranchRoot() call, which in modes
162
- * with a separate settings branch provisions that branch's git workspace: an
163
- * EFS round trip in prod. `createBuildCanopy` (build-canopy.ts) runs outside
164
- * a request or Next.js build phase, so neither other guard fires for it, and
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.
209
+ * What a batch read (listEntries / buildContentTree) lists from: the branch's
210
+ * schema plus its path-ACL predicate, or null when the branch yields nothing
211
+ * this user may see. The branch resolves as for `read` (`resolveBranch`). At
212
+ * build time and on static deployments `branch` selects nothing, as for
213
+ * `read` (see the module doc).
167
214
  *
168
- * Compared by reference to the STATIC_DEPLOY_USER singleton, not by group
169
- * membership, so it stays scoped to the synthetic build identity: a real
170
- * authenticated admin at request time still goes through the real check.
171
- *
172
- * Deliberately NOT wrapped in try/catch — createContentAccessChecker is
173
- * fail-loud by contract, and swallowing would serve an unfiltered listing.
215
+ * Memoized per getContext call and per branch name, on the in-flight
216
+ * promise; a null result is memoized too, so one request answers a given
217
+ * name consistently. The memo is request-scoped, so it carries no
218
+ * generation protocol (docs/concurrency.md, "Adding a call-scoped memo").
174
219
  */
175
- let visibilityPromise = null;
176
- const resolveVisibilityImpl = async () => {
177
- if (isDeployedStatic(services.config) || isBuildMode() || user === STATIC_DEPLOY_USER)
178
- return {};
179
- const { branchContext, branchRoot } = await resolveSchemaContext();
180
- const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
181
- return { shouldInclude: (physicalPath) => checkAccess(physicalPath, 'read').allowed };
220
+ const listingSources = new Map();
221
+ const resolveListingSource = (branch) => {
222
+ if (!isBranchOption(branch))
223
+ return Promise.resolve(null);
224
+ const requested = otherBranch(branch);
225
+ const key = requested ?? activeBranch;
226
+ let source = listingSources.get(key);
227
+ if (!source) {
228
+ source = resolveListingSourceImpl(requested);
229
+ listingSources.set(key, source);
230
+ }
231
+ return source;
182
232
  };
183
- const resolveVisibility = () => {
184
- if (!visibilityPromise) {
185
- visibilityPromise = resolveVisibilityImpl();
233
+ const resolveListingSourceImpl = async (requested) => {
234
+ const operatingMode = services.config.mode;
235
+ const branchContext = await resolveBranch(requested);
236
+ if (!branchContext)
237
+ return null;
238
+ // resolveBranch has already checked any other branch.
239
+ if (requested === undefined &&
240
+ enforceAcls &&
241
+ !services.checkBranchAccess(branchContext, user).allowed) {
242
+ return null;
186
243
  }
187
- return visibilityPromise;
244
+ const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
245
+ const contentRootName = services.config.contentRoot || 'content';
246
+ const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
247
+ if (!enforceAcls)
248
+ return { branchRoot, flatSchema, contentRootName, visibility: {} };
249
+ // The path-ACL predicate keeps an unfiltered listing on this ACL-enforcing
250
+ // context from disclosing entry `data` for paths the user cannot `read()`.
251
+ // `services.createContentAccessChecker` is the shared batch primitive
252
+ // (api/entries.ts uses it too): it resolves the request-constant work —
253
+ // branch access, the settings/permissions root, the rule set — once, and
254
+ // returns a synchronous per-path check, so the per-entry cost is an admin
255
+ // short-circuit or one minimatch per configured rule, with no extra I/O.
256
+ //
257
+ // Deliberately NOT wrapped in try/catch — createContentAccessChecker is
258
+ // fail-loud by contract, and swallowing would serve an unfiltered listing.
259
+ const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
260
+ return {
261
+ branchRoot,
262
+ flatSchema,
263
+ contentRootName,
264
+ visibility: { shouldInclude: (logicalPath) => checkAccess(logicalPath, 'read').allowed },
265
+ };
188
266
  };
189
267
  const buildContentTree = async (options) => {
190
- const { branchRoot, flatSchema, contentRootName } = await resolveSchemaContext();
191
- return buildContentTreeImpl(branchRoot, flatSchema, contentRootName, options, await resolveVisibility());
268
+ const { branch, ...treeOptions } = options ?? {};
269
+ const source = await resolveListingSource(branch);
270
+ if (!source)
271
+ return [];
272
+ return buildContentTreeImpl(source.branchRoot, source.flatSchema, source.contentRootName, treeOptions, source.visibility);
192
273
  };
193
274
  const listEntries = async (options) => {
194
- const { branchRoot, flatSchema, contentRootName } = await resolveSchemaContext();
195
- return listEntriesImpl(branchRoot, flatSchema, contentRootName, options, await resolveVisibility());
275
+ const { branch, ...listOptions } = options ?? {};
276
+ const source = await resolveListingSource(branch);
277
+ if (!source)
278
+ return [];
279
+ return listEntriesImpl(source.branchRoot, source.flatSchema, source.contentRootName, listOptions, source.visibility);
196
280
  };
197
281
  return {
198
282
  read,
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { CanopyClientConfig } from '../config/index.js';
3
3
  import type { EditorProps } from './Editor.js';
4
- export interface CanopyEditorProps extends Omit<EditorProps, 'collections' | 'previewBaseByCollection' | 'title' | 'subtitle' | 'themeOptions' | 'entries' | 'contentRoot' | 'operatingMode'> {
4
+ export interface CanopyEditorProps extends Omit<EditorProps, 'collections' | 'previewBaseByCollection' | 'previewPrefix' | 'title' | 'subtitle' | 'themeOptions' | 'entries' | 'contentRoot' | 'operatingMode'> {
5
5
  config: CanopyClientConfig;
6
6
  entries?: EditorProps['entries'];
7
7
  }
@@ -19,6 +19,6 @@ export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialV
19
19
  const resolvedTheme = config.editor?.theme ?? undefined;
20
20
  // The gate owns the SWRProvider (keyed by user id), so it sits between the API client and the
21
21
  // editor: see EditorAuthGate for what a signed-out or lapsed session renders.
22
- return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: previewBase, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
22
+ return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: previewBase, previewPrefix: config.editor?.previewPrefix, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
23
23
  };
24
24
  export default CanopyEditor;
@@ -58,6 +58,8 @@ export interface EditorProps {
58
58
  onCreateEntry?: (collectionPath: LogicalPath) => Promise<void> | void;
59
59
  themeOptions?: CanopyThemeOptions;
60
60
  previewBaseByCollection?: Record<string, string>;
61
+ /** `editor.previewPrefix` from config: put in front of every preview iframe `src` (see `buildPreviewSrc`). */
62
+ previewPrefix?: string;
61
63
  currentUser?: string;
62
64
  canResolveComments?: boolean;
63
65
  /** `media.publicBaseUrl` from config - prefixed onto asset URLs the editor builds (MediaLibrary/ImageField/MDX image dialog). Undefined means root-relative (editor and site share an origin). */
@@ -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';
@@ -32,7 +33,7 @@ import { MediaLibrary } from './media/MediaLibrary.js';
32
33
  * High-level editor wrapper that wires entry navigation, form rendering,
33
34
  * saving/loading, and preview rendering using entry definitions.
34
35
  */
35
- export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, currentUser = 'current-user', canResolveComments = true, assetBaseUrl, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
36
+ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, previewPrefix, currentUser = 'current-user', canResolveComments = true, assetBaseUrl, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
36
37
  const [branchesLoading, setBranchesLoading] = useState(false);
37
38
  const [entriesLoading, setEntriesLoading] = useState(false);
38
39
  const [commentsLoading] = useState(false);
@@ -98,6 +99,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
98
99
  resolvePreviewSrc: (entry) => buildPreviewSrc(entry, {
99
100
  branchName: branchNameState,
100
101
  previewBaseByCollection,
102
+ previewPrefix,
101
103
  contentRoot,
102
104
  basePath,
103
105
  }),
@@ -224,9 +226,8 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
224
226
  // branch's content. Writing it would (a) show it as the new branch's
225
227
  // content and (b) satisfy the skip gate above, permanently suppressing
226
228
  // the new branch's own load -- which also leaves the new branch's OCC
227
- // token unset, turning the next save into a blind overwrite that can
228
- // never 409. Drop it instead; the new branch has its own load running
229
- // under its own key.
229
+ // token unset, so saveEntry refuses every save of the entry. Drop it
230
+ // instead; the new branch has its own load running under its own key.
230
231
  if (currentBranchRef.current !== requestBranch)
231
232
  return;
232
233
  setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
@@ -644,7 +645,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
644
645
  ? 'Setting up your branch workspace…'
645
646
  : entriesInitializing
646
647
  ? 'Loading content…'
647
- : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(CenteredMessage, { children: "You don't have permission to edit this content." })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
648
+ : '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
649
  const contentId = currentEntry?.contentId;
649
650
  if (contentId) {
650
651
  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) => (_jsx(Paper, { withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { size: "xs", fw: 700, children: [label, " #", idx + 1] }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { label: objectField.label, fields: objectField.fields, value: item, onChange: (next) => {
139
- const nextItems = [...items];
140
- nextItems[idx] = next;
141
- update(nextItems);
142
- }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])))), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
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 ? 'Provisioning may be in progress' : undefined,
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,17 @@ 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;
124
- 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 in progress)`
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)" })] }));
153
+ const baseWarning = baseRefreshWarning(lastGitSync?.baseRefresh);
154
+ const { build } = status;
155
+ // Absent for a worker that predates the field, which is not evidence of skew;
156
+ // nor is a stale or absent worker's leftover status file, which names no running build.
157
+ const workerVersion = status.workerStatus?.workerVersion || undefined;
158
+ const versionSkew = status.worker.state === 'alive' &&
159
+ workerVersion !== undefined &&
160
+ workerVersion !== build.canopycmsVersion;
161
+ 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.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), 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] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] })] }), 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
162
+ ? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
163
+ : ''] }), 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
164
  }
128
165
  function TasksTab({ health }) {
129
166
  const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
@@ -160,6 +197,7 @@ function TasksTab({ health }) {
160
197
  function BranchesTab({ health }) {
161
198
  const { branchHealth, branchHealthLoading } = health;
162
199
  const entries = branchHealth?.entries ?? [];
200
+ const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
163
201
  const handleMarkMergedClick = (branchName) => {
164
202
  modals.openConfirmModal({
165
203
  title: 'Mark branch as merged',
@@ -193,9 +231,9 @@ function BranchesTab({ health }) {
193
231
  if (entries.length === 0) {
194
232
  return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
195
233
  }
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))) })] }) }));
234
+ 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
235
  }
198
- function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
236
+ function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
199
237
  if (entry.kind === 'healthy' && entry.branch) {
200
238
  const b = entry.branch;
201
239
  // Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
@@ -207,7 +245,7 @@ function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
207
245
  const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
208
246
  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
247
  ? `${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
248
+ : '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
249
  .map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
212
250
  .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
251
  }
@@ -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;